diff --git a/dev/README.md b/dev/README.md deleted file mode 100644 index db45e4eb..00000000 --- a/dev/README.md +++ /dev/null @@ -1,3 +0,0 @@ -```bash -pip install -e ./microsoft-agents-testing/ --config-settings editable_mode=compat -``` \ No newline at end of file diff --git a/dev/testing/README.md b/dev/testing/README.md new file mode 100644 index 00000000..ff0ee91a --- /dev/null +++ b/dev/testing/README.md @@ -0,0 +1,13 @@ +## Testing + +This folder contains three test-related directories: + +`cross-sdk-tests`: End-to-end tests across all SDKs (Python, JavaScript, .NET). This is a work in progress. + +`microsoft-agents-testing`: This is the testing framework used to facilitate testing agents. This is only for internal development purposes. + +`python-sdk-tests`: These are integration tests related to the Python SDK. These are an extension of the Python SDK's unit tests. These tests are more specific that the ones in `cross-sdk-tests` because they look into the internals of Python SDK components for the running agents while the other test suite communicates purely over HTTP/HTTPS. + +## Running tests and installation + +The instructions to install the `microsoft-agents-testing` library are specificed in `microsoft-agents-testing/README.md`. To run the `python-sdk-tests`, `cd` into that directory and run `pytest` via Powershell. `cross-sdk-tests` still does not have an entry point for testing. \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/.gitignore b/dev/testing/cross-sdk-tests/.gitignore new file mode 100644 index 00000000..510e2f50 --- /dev/null +++ b/dev/testing/cross-sdk-tests/.gitignore @@ -0,0 +1,244 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates +target/ + +# Cake +/.cake +/version.txt +/PSRunCmds*.ps1 + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +/bin/ +/binSigned/ +/obj/ +Drop/ +target/ +Symbols/ +objd/ +.config/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +#nodeJS stuff +/node_modules/ + +#local development +appsettings.local.json +appsettings.Development.json +appsettings.Development* +appsettings.Production.json +**/[Aa]ppManifest/*.zip +.deployment + +# JetBrains Rider +*.sln.iml +.idea + +# Mac files +.DS_Store + +# VS Code files +.vscode +src/samples/ModelContextProtocol/GitHubMCPServer/Properties/ServiceDependencies/GitHubMCPServer20250311143114 - Web Deploy/profile.arm.json + +# Claude Code temporary files +tmpclaude* + + +node_modules/ +dist/ +*.env +*.key +*.pem +test-report.xml +tsconfig.tsbuildinfo +devTools/ \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/__init__.py b/dev/testing/cross-sdk-tests/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/__init__.py rename to dev/testing/cross-sdk-tests/__init__.py diff --git a/dev/testing/cross-sdk-tests/agents/core-agent/README.md b/dev/testing/cross-sdk-tests/agents/core-agent/README.md new file mode 100644 index 00000000..2c07f981 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/core-agent/README.md @@ -0,0 +1,3 @@ +# Core Agent + +An agent that has various routes to test diverse the Agents SDK core functionalities. \ No newline at end of file diff --git a/dev/tests/agents/basic_agent/python/README.md b/dev/testing/cross-sdk-tests/agents/core-agent/python/README.md similarity index 100% rename from dev/tests/agents/basic_agent/python/README.md rename to dev/testing/cross-sdk-tests/agents/core-agent/python/README.md diff --git a/dev/microsoft-agents-testing/tests/core/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/__init__.py diff --git a/dev/tests/agents/basic_agent/python/env.TEMPLATE b/dev/testing/cross-sdk-tests/agents/core-agent/python/env.TEMPLATE similarity index 100% rename from dev/tests/agents/basic_agent/python/env.TEMPLATE rename to dev/testing/cross-sdk-tests/agents/core-agent/python/env.TEMPLATE diff --git a/dev/tests/agents/basic_agent/python/pre_requirements.txt b/dev/testing/cross-sdk-tests/agents/core-agent/python/pre_requirements.txt similarity index 100% rename from dev/tests/agents/basic_agent/python/pre_requirements.txt rename to dev/testing/cross-sdk-tests/agents/core-agent/python/pre_requirements.txt diff --git a/dev/tests/agents/basic_agent/python/requirements.txt b/dev/testing/cross-sdk-tests/agents/core-agent/python/requirements.txt similarity index 100% rename from dev/tests/agents/basic_agent/python/requirements.txt rename to dev/testing/cross-sdk-tests/agents/core-agent/python/requirements.txt diff --git a/dev/microsoft-agents-testing/tests/core/fluent/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/agent.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/agent.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/agent.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/agent.py diff --git a/dev/tests/agents/basic_agent/python/src/app.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/app.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/app.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/app.py diff --git a/dev/tests/agents/basic_agent/python/src/config.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/config.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/config.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/config.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/weather_forecast_agent.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/weather_forecast_agent.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/adaptive_card_plugin.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/adaptive_card_plugin.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/date_time_plugin.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/date_time_plugin.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast_plugin.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast_plugin.py diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/README.md b/dev/testing/cross-sdk-tests/agents/quickstart/README.md new file mode 100644 index 00000000..e37735c2 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/README.md @@ -0,0 +1,3 @@ +# Quickstart Agent + +This agent echos responses back to the user. As presently configured, the agent enables JWT token validation. \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/_run_agent.ps1 b/dev/testing/cross-sdk-tests/agents/quickstart/js/_run_agent.ps1 new file mode 100644 index 00000000..0970ac91 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/_run_agent.ps1 @@ -0,0 +1,4 @@ +npm install + +npm run build +npm run start:anon \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/env.TEMPLATE b/dev/testing/cross-sdk-tests/agents/quickstart/js/env.TEMPLATE new file mode 100644 index 00000000..e170043b --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/env.TEMPLATE @@ -0,0 +1,9 @@ +# rename to .env +connections__serviceConnection__settings__clientId= # App ID of the App Registration used to log in. +connections__serviceConnection__settings__clientSecret= # Client secret of the App Registration used to log in +connections__serviceConnection__settings__tenantId= # Tenant ID of the App Registration used to log in + +connectionsMap__0__connection=serviceConnection +connectionsMap__0__serviceUrl=* + +DEBUG=agents:*:error \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/package-lock.json b/dev/testing/cross-sdk-tests/agents/quickstart/js/package-lock.json new file mode 100644 index 00000000..64fc102a --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/package-lock.json @@ -0,0 +1,3077 @@ +{ + "name": "node-empty-agent", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-empty-agent", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@microsoft/agents-hosting-express": "^1.1.0" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.16", + "@types/node": "^22.15.18", + "npm-run-all": "^4.1.5", + "typescript": "^5.8.3" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.13.3", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.3.tgz", + "integrity": "sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.4.tgz", + "integrity": "sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.13.3", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@microsoft/agents-activity": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-activity/-/agents-activity-1.1.1.tgz", + "integrity": "sha512-L7PHEHKFge99aIxV9eA7uFY3n9goYKzxcWaqLXGmxq3wMsau8hdsPzZgpV77LOQWQynLO3M5cbD8AavcVZszlQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "uuid": "^11.1.0", + "zod": "3.25.75" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/agents-activity/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@microsoft/agents-hosting": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting/-/agents-hosting-1.1.1.tgz", + "integrity": "sha512-ZO/BU0d/NxSlbg/W4SvtHDvwS4GDYrMG5CpBh+m2vnqkl6tphM0kkfbSYZFef0BoftrinOdPZcSvdvmVqpbM2w==", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.10.1", + "@azure/msal-node": "^3.8.2", + "@microsoft/agents-activity": "1.1.1", + "axios": "^1.13.2", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.2.0", + "object-path": "^0.11.8" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/agents-hosting-express": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting-express/-/agents-hosting-express-1.1.1.tgz", + "integrity": "sha512-CDStIx23U2zyS/4nZoeVgrVlVbQ+EasoqR2dLq7IfU4rUyuUrKGPdlO55rcfS6Z/spLkhCnX35jbD6EBqrTkJg==", + "license": "MIT", + "dependencies": { + "@microsoft/agents-hosting": "1.1.1", + "express": "^5.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/m365agentsplayground": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@microsoft/m365agentsplayground/-/m365agentsplayground-0.2.18.tgz", + "integrity": "sha512-8okNQ+fNQPPMBW/OSIudoCApBKqKxADNFIMivUGy/eaX9v8tFIG/gFo7DLwpaCzNuc1M8oOW9Sse1mX08Dxo0A==", + "dev": true, + "bin": { + "agentsplayground": "cli.js", + "teamsapptester": "cli.js" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.18.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz", + "integrity": "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", + "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-path": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/object-path/-/object-path-0.11.8.tgz", + "integrity": "sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==", + "license": "MIT", + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", + "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "dev": true + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.75", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.75.tgz", + "integrity": "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/package.json b/dev/testing/cross-sdk-tests/agents/quickstart/js/package.json new file mode 100644 index 00000000..e4cdab59 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/package.json @@ -0,0 +1,29 @@ +{ + "name": "node-empty-agent", + "version": "1.0.0", + "private": true, + "description": "Agents echo bot sample", + "author": "Microsoft", + "license": "MIT", + "main": "./dist/index.js", + "scripts": { + "prebuild": "npm ci", + "build": "tsc --build", + "prestart": "npm run build", + "prestart:anon": "npm run build", + "start:anon": "node ./dist/index.js", + "start": "node --env-file .env ./dist/index.js", + "test-tool": "agentsplayground", + "test": "npm-run-all -p -r start:anon test-tool" + }, + "dependencies": { + "@microsoft/agents-hosting-express": "^1.1.0" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.16", + "@types/node": "^22.15.18", + "npm-run-all": "^4.1.5", + "typescript": "^5.8.3" + }, + "keywords": [] +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/src/index.ts b/dev/testing/cross-sdk-tests/agents/quickstart/js/src/index.ts new file mode 100644 index 00000000..5796e398 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/src/index.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { ActivityTypes } from '@microsoft/agents-activity'; +import { AgentApplication, AttachmentDownloader, MemoryStorage, TurnContext, TurnState } from '@microsoft/agents-hosting'; +import { startServer } from '@microsoft/agents-hosting-express'; + +// Create custom conversation state properties. This is +// used to store customer properties in conversation state. +interface ConversationState { + count: number; +} +type ApplicationTurnState = TurnState + +// Register IStorage. For development, MemoryStorage is suitable. +// For production Agents, persisted storage should be used so +// that state survives Agent restarts, and operates correctly +// in a cluster of Agent instances. +const storage = new MemoryStorage() + +const downloader = new AttachmentDownloader() + +const agentApp = new AgentApplication({ + storage, + fileDownloaders: [downloader] +}) + +// Display a welcome message when members are added +agentApp.onConversationUpdate('membersAdded', async (context: TurnContext, state: ApplicationTurnState) => { + await context.sendActivity('Hello and Welcome!') +}) + +// Listen for ANY message to be received. MUST BE AFTER ANY OTHER MESSAGE HANDLERS +agentApp.onActivity(ActivityTypes.Message, async (context: TurnContext, state: ApplicationTurnState) => { + // Increment count state + let count = state.conversation.count ?? 0 + state.conversation.count = ++count + + // Echo back users message + await context.sendActivity(`[${count}] You said: ${context.activity.text}`) +}) + +startServer(agentApp) \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/tsconfig.json b/dev/testing/cross-sdk-tests/agents/quickstart/js/tsconfig.json new file mode 100644 index 00000000..0e188450 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "incremental": true, + "lib": ["ES2021"], + "target": "es2019", + "module": "commonjs", + "declaration": true, + "sourceMap": true, + "composite": true, + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + } +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/net/AspNetExtensions.cs b/dev/testing/cross-sdk-tests/agents/quickstart/net/AspNetExtensions.cs new file mode 100644 index 00000000..944e6844 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/net/AspNetExtensions.cs @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Authentication; +using Microsoft.Agents.Core; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +public static class AspNetExtensions +{ + private static readonly ConcurrentDictionary> _openIdMetadataCache = new(); + + /// + /// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent using settings in configuration. + /// + /// + /// + /// Name of the config section to read. + /// Optional logger to use for authentication event logging. + /// + /// This extension reads settings from configuration. If configuration is missing JWT token + /// is not enabled. + ///

The minimum, but typical, configuration is:

+ /// + /// "TokenValidation": { + /// "Enabled": boolean, + /// "Audiences": [ + /// "{{ClientId}}" // this is the Client ID used for the Azure Bot + /// ], + /// "TenantId": "{{TenantId}}" + /// } + /// + /// The full options are: + /// + /// "TokenValidation": { + /// "Enabled": boolean, + /// "Audiences": [ + /// "{required:agent-appid}" + /// ], + /// "TenantId": "{recommended:tenant-id}", + /// "ValidIssuers": [ + /// "{default:Public-AzureBotService}" + /// ], + /// "IsGov": {optional:false}, + /// "AzureBotServiceOpenIdMetadataUrl": optional, + /// "OpenIdMetadataUrl": optional, + /// "AzureBotServiceTokenHandling": "{optional:true}" + /// "OpenIdMetadataRefresh": "optional-12:00:00" + /// } + /// + ///
+ public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation") + { + IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName); + + if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true)) + { + // Noop if TokenValidation section missing or disabled. + System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled"); + return; + } + + services.AddAgentAspNetAuthentication(tokenValidationSection.Get()!); + } + + /// + /// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent. + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions) + { + AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions)); + + // Must have at least one Audience. + if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId"); + } + + // Audience values must be GUID's + foreach (var audience in validationOptions.Audiences) + { + if (!Guid.TryParse(audience, out _)) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID"); + } + } + + // If ValidIssuers is empty, default for ABS Public Cloud + if (validationOptions.ValidIssuers == null || validationOptions.ValidIssuers.Count == 0) + { + validationOptions.ValidIssuers = + [ + "https://api.botframework.com", + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", + "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", + ]; + + if (!string.IsNullOrEmpty(validationOptions.TenantId) && Guid.TryParse(validationOptions.TenantId, out _)) + { + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, validationOptions.TenantId)); + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, validationOptions.TenantId)); + } + } + + // If the `AzureBotServiceOpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate ABS tokens. + if (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl)) + { + validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl; + } + + // If the `OpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate Entra ID tokens. + if (string.IsNullOrEmpty(validationOptions.OpenIdMetadataUrl)) + { + validationOptions.OpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl; + } + + var openIdMetadataRefresh = validationOptions.OpenIdMetadataRefresh ?? BaseConfigurationManager.DefaultAutomaticRefreshInterval; + + _ = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + ValidIssuers = validationOptions.ValidIssuers, + ValidAudiences = validationOptions.Audiences, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + }; + + // Using Microsoft.IdentityModel.Validators + options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + + options.Events = new JwtBearerEvents + { + // Create a ConfigurationManager based on the requestor. This is to handle ABS non-Entra tokens. + OnMessageReceived = async context => + { + string authorizationHeader = context.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authorizationHeader)) + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + string[] parts = authorizationHeader?.Split(' ')!; + if (parts.Length != 2 || parts[0] != "Bearer") + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + JwtSecurityToken token = new(parts[1]); + string issuer = token.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim)?.Value!; + + if (validationOptions.AzureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer)) + { + // Use the Azure Bot authority for this configuration manager + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.AzureBotServiceOpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.AzureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + else + { + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.OpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.OpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + + await Task.CompletedTask.ConfigureAwait(false); + }, + + OnTokenValidated = context => + { + return Task.CompletedTask; + }, + OnForbidden = context => + { + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + return Task.CompletedTask; + } + }; + }); + } + + public class TokenValidationOptions + { + public IList? Audiences { get; set; } + + /// + /// TenantId of the Azure Bot. Optional but recommended. + /// + public string? TenantId { get; set; } + + /// + /// Additional valid issuers. Optional, in which case the Public Azure Bot Service issuers are used. + /// + public IList? ValidIssuers { get; set; } + + /// + /// Can be omitted, in which case public Azure Bot Service and Azure Cloud metadata urls are used. + /// + public bool IsGov { get; set; } = false; + + /// + /// Azure Bot Service OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? AzureBotServiceOpenIdMetadataUrl { get; set; } + + /// + /// Entra OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? OpenIdMetadataUrl { get; set; } + + /// + /// Determines if Azure Bot Service tokens are handled. Defaults to true and should always be true until Azure Bot Service sends Entra ID token. + /// + public bool AzureBotServiceTokenHandling { get; set; } = true; + + /// + /// OpenIdMetadata refresh interval. Defaults to 12 hours. + /// + public TimeSpan? OpenIdMetadataRefresh { get; set; } + } +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/net/MyAgent.cs b/dev/testing/cross-sdk-tests/agents/quickstart/net/MyAgent.cs new file mode 100644 index 00000000..9e65e5f1 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/net/MyAgent.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Builder.State; +using Microsoft.Agents.Core.Models; +using System.Threading.Tasks; +using System.Threading; + +namespace QuickStart; + +public class MyAgent : AgentApplication +{ + public MyAgent(AgentApplicationOptions options) : base(options) + { + OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); + OnActivity(ActivityTypes.Message, OnMessageAsync, rank: RouteRank.Last); + } + + private async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + foreach (ChannelAccount member in turnContext.Activity.MembersAdded) + { + if (member.Id != turnContext.Activity.Recipient.Id) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Hello and Welcome!"), cancellationToken); + } + } + } + + private async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync($"You said: {turnContext.Activity.Text}", cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/net/Program.cs b/dev/testing/cross-sdk-tests/agents/quickstart/net/Program.cs new file mode 100644 index 00000000..24375d2b --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/net/Program.cs @@ -0,0 +1,51 @@ + +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using QuickStart; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.Agents.Storage; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddHttpClient(); + +// Add AgentApplicationOptions from appsettings section "AgentApplication". +builder.AddAgentApplicationOptions(); + +// Add the AgentApplication, which contains the logic for responding to +// user messages. +builder.AddAgent(); + +// Register IStorage. For development, MemoryStorage is suitable. +// For production Agents, persisted storage should be used so +// that state survives Agent restarts, and operates correctly +// in a cluster of Agent instances. +builder.Services.AddSingleton(); + +// Configure the HTTP request pipeline. + +// Add AspNet token validation for Azure Bot Service and Entra. Authentication is +// configured in the appsettings.json "TokenValidation" section. +builder.Services.AddControllers(); +builder.Services.AddAgentAspNetAuthentication(builder.Configuration); + +WebApplication app = builder.Build(); + +// Enable AspNet authentication and authorization +app.UseAuthentication(); +app.UseAuthorization(); + +// Map GET "/" +app.MapAgentRootEndpoint(); + +// Map the endpoints for all agents using the [AgentInterface] attribute. +// If there is a single IAgent/AgentApplication, the endpoints will be mapped to (e.g. "/api/message"). +app.MapAgentApplicationEndpoints(requireAuth: !app.Environment.IsDevelopment()); + +app.Urls.Add($"http://localhost:3978"); + +app.Run(); diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/net/Quickstart.csproj b/dev/testing/cross-sdk-tests/agents/quickstart/net/Quickstart.csproj new file mode 100644 index 00000000..14a818ca --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/net/Quickstart.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + latest + disable + enable + + + + + + + + \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/net/_run_agent.ps1 b/dev/testing/cross-sdk-tests/agents/quickstart/net/_run_agent.ps1 new file mode 100644 index 00000000..bca86d74 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/net/_run_agent.ps1 @@ -0,0 +1,2 @@ +dotnet build +dotnet run \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/python/_run_agent.ps1 b/dev/testing/cross-sdk-tests/agents/quickstart/python/_run_agent.ps1 new file mode 100644 index 00000000..3dace17f --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/python/_run_agent.ps1 @@ -0,0 +1,6 @@ +python -m venv venv +.\venv\Scripts\Activate.ps1 + +pip install -r requirements.txt + +python -m src.main \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/test_examples.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/env.TEMPLATE similarity index 100% rename from dev/microsoft-agents-testing/tests/test_examples.py rename to dev/testing/cross-sdk-tests/agents/quickstart/python/env.TEMPLATE diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/python/requirements.txt b/dev/testing/cross-sdk-tests/agents/quickstart/python/requirements.txt new file mode 100644 index 00000000..0ca6c392 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/python/requirements.txt @@ -0,0 +1,3 @@ +microsoft-agents-hosting-core +microsoft-agents-hosting-aiohttp +microsoft-agents-authentication-msal \ No newline at end of file diff --git a/dev/tests/__init__.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/__init__.py similarity index 100% rename from dev/tests/__init__.py rename to dev/testing/cross-sdk-tests/agents/quickstart/python/src/__init__.py diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/python/src/agent.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/agent.py new file mode 100644 index 00000000..33571cbf --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/agent.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +import sys +import traceback +from dotenv import load_dotenv + +from os import environ +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + Authorization, + AgentApplication, + TurnState, + TurnContext, + MemoryStorage, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.activity import load_configuration_from_env + +load_dotenv() +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) + +@AGENT_APP.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState): + await context.send_activity( + "Welcome to the empty agent! " + "This agent is designed to be a starting point for your own agent development." + ) + return True + + +@AGENT_APP.message(re.compile(r"^hello$")) +async def on_hello(context: TurnContext, _state: TurnState): + await context.send_activity("Hello!") + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + await context.send_activity(f"you said: {context.activity.text}") + + +@AGENT_APP.error +async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/python/src/main.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/main.py new file mode 100644 index 00000000..9139fe33 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/main.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), +) diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/python/src/start_server.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/start_server.py new file mode 100644 index 00000000..9a747e75 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/start_server.py @@ -0,0 +1,39 @@ +from os import environ +import logging + +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.aiohttp import ( + start_agent_process, + jwt_authorization_middleware, + CloudAdapter, +) +from aiohttp.web import Request, Response, Application, run_app + +logger = logging.getLogger(__name__) + +def start_server( + agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration +): + async def entry_point(req: Request) -> Response: + + logger.info("Request received at /api/messages endpoint.") + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + + return await start_agent_process( + req, + agent, + adapter, + ) + + APP = Application(middlewares=[jwt_authorization_middleware]) + APP.router.add_post("/api/messages", entry_point) + + APP["agent_configuration"] = auth_configuration + APP["agent_app"] = agent_application + APP["adapter"] = agent_application.adapter + + try: + run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + except Exception as error: + raise error diff --git a/dev/tests/env.TEMPLATE b/dev/testing/cross-sdk-tests/env.TEMPLATE similarity index 100% rename from dev/tests/env.TEMPLATE rename to dev/testing/cross-sdk-tests/env.TEMPLATE diff --git a/dev/tests/pytest.ini b/dev/testing/cross-sdk-tests/pytest.ini similarity index 97% rename from dev/tests/pytest.ini rename to dev/testing/cross-sdk-tests/pytest.ini index 2c3d00cb..9908f4bf 100644 --- a/dev/tests/pytest.ini +++ b/dev/testing/cross-sdk-tests/pytest.ini @@ -7,7 +7,7 @@ filterwarnings = ignore::aiohttp.web.NotAppKeyWarning # Test discovery configuration -testpaths = ./ +testpaths = tests python_files = test_*.py *_test.py python_classes = Test* python_functions = test_* diff --git a/dev/tests/agents/basic_agent/__init__.py b/dev/testing/cross-sdk-tests/tests/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/__init__.py rename to dev/testing/cross-sdk-tests/tests/__init__.py diff --git a/dev/testing/cross-sdk-tests/tests/_common/__init__.py b/dev/testing/cross-sdk-tests/tests/_common/__init__.py new file mode 100644 index 00000000..b04ac09c --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/__init__.py @@ -0,0 +1,12 @@ +from .types import SDKVersion + +from .utils import ( + create_agent_path, + create_scenario, +) + +__all__ = [ + "create_agent_path", + "create_scenario", + "SDKVersion", +] \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/constants.py b/dev/testing/cross-sdk-tests/tests/_common/constants.py new file mode 100644 index 00000000..49f5acda --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/constants.py @@ -0,0 +1,7 @@ +import pathlib + +_AGENTS_DIR_NAME = "agents" +AGENTS_PATH = pathlib.Path.cwd() / _AGENTS_DIR_NAME +ENTRY_POINT_NAME = "_run_agent.ps1" + +DEFAULT_LOCAL_AGENT_ENDPOINT = "http://localhost:3978/api/messages" diff --git a/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py b/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py new file mode 100644 index 00000000..c19cb8fd --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py @@ -0,0 +1,78 @@ +import asyncio +import shutil +import subprocess + +from pathlib import Path + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from microsoft_agents.testing import ( + ActivityTemplate, + ClientConfig, + ExternalScenario, + ScenarioConfig, +) +from microsoft_agents.testing.core import ClientFactory + +from .constants import DEFAULT_LOCAL_AGENT_ENDPOINT + +_TEMPLATE = { + "channel_id": "webchat", + "locale": "en-US", + "conversation": {"id": "conv1"}, + "from": {"id": "user1", "name": "User"}, + "recipient": {"id": "bot", "name": "Bot"}, +} + +client_config=ClientConfig( + activity_template=ActivityTemplate(_TEMPLATE) +) + +class SourceScenario(ExternalScenario): + """Base class for script-based test scenarios.""" + + def __init__( + self, + script_path: str, + delay: float = 0.0, + config: ScenarioConfig | None = None + ) -> None: + super().__init__(DEFAULT_LOCAL_AGENT_ENDPOINT, config) + self._script_path = Path(script_path) + self._delay = delay + + @asynccontextmanager + async def _run_script(self) -> AsyncIterator[None]: + + script_path = self._script_path.resolve() + + runner = shutil.which("pwsh") or shutil.which("powershell") + if runner is None: + raise FileNotFoundError("Could not find pwsh or powershell in PATH") + + try: + process = subprocess.Popen( + [runner, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=script_path.parent, + shell=True, # Needed to ensure the process group is correctly set up for termination + ) + + # wait for the agent to start running + await asyncio.sleep(self._delay) + + yield + + process.terminate() + except Exception as ex: + process.kill() + raise ex + + @asynccontextmanager + async def run(self) -> AsyncIterator[ClientFactory]: + """Start callback server and yield a client factory.""" + async with self._run_script(): + async with super().run() as factory: + yield factory \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/types.py b/dev/testing/cross-sdk-tests/tests/_common/types.py new file mode 100644 index 00000000..305ef1a3 --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/types.py @@ -0,0 +1,7 @@ +from enum import Enum + +class SDKVersion(str, Enum): + + PYTHON = "python" + JS = "js" + NET = "net" \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/utils.py b/dev/testing/cross-sdk-tests/tests/_common/utils.py new file mode 100644 index 00000000..a545d3f6 --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/utils.py @@ -0,0 +1,21 @@ +from microsoft_agents.testing import Scenario + +from . import constants +from .source_scenario import SourceScenario +from .types import SDKVersion + +def create_agent_path(agent_name: str, sdk_version: SDKVersion) -> str: + + agent_path = constants.AGENTS_PATH / agent_name / sdk_version.value / constants.ENTRY_POINT_NAME + if not agent_path.exists(): + raise FileNotFoundError(f"Agent path does not exist: {agent_path}") + + return str(agent_path.resolve()) + +def create_scenario(agent_name: str, sdk_version: SDKVersion, delay: float = 5.0) -> Scenario: + + agent_path = create_agent_path(agent_name, sdk_version) + return SourceScenario( + agent_path, + delay=delay, + ) \ No newline at end of file diff --git a/dev/tests/agents/basic_agent/python/__init__.py b/dev/testing/cross-sdk-tests/tests/basic/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/__init__.py rename to dev/testing/cross-sdk-tests/tests/basic/__init__.py diff --git a/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py b/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py new file mode 100644 index 00000000..121cdbb9 --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py @@ -0,0 +1,71 @@ +import pytest + +from microsoft_agents.testing import AgentClient + +from tests._common import ( + create_scenario, + SDKVersion, +) + +AGENT_NAME = "quickstart" +PYTHON_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.PYTHON) +NET_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.NET) +JS_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.JS) + +class BaseTestQuickstart: + """Integration tests for the Quickstart scenario.""" + + @pytest.mark.asyncio + async def test_conversation_update(self, agent_client: AgentClient): + """Test sending a conversation update activity.""" + input_activity = agent_client.template.create({ + "type": "conversationUpdate", + "members_added": [ + {"id": "bot-id", "name": "Bot"}, + {"id": "user1", "name": "User"}, + ], + "textFormat": "plain", + "entities": [ + { + "type": "ClientCapabilities", + "requiresBotState": True, + "supportsTts": True + } + ], + "channel_data": {"clientActivityId": 123} + }) + + await agent_client.send(input_activity, wait=10) + agent_client.expect().that_for_one(type="message", text="~Welcome") + + # @pytest.mark.asyncio + async def test_send_hello(self, agent_client: AgentClient): + """Test sending a 'hello' message and receiving a response.""" + await agent_client.send("hello", wait=10) + agent_client.expect().that_for_one(type="message", text="Hello!") + + @pytest.mark.asyncio + async def test_send_hi(self, agent_client: AgentClient): + """Test sending a 'hi' message and receiving a response.""" + + await agent_client.send("hi", wait=10) + responses = agent_client.recent() + + assert len(responses) == 2 + assert len(agent_client.history()) == 2 + + agent_client.expect().that_for_one(type="message", text="you said: hi") + agent_client.expect().that_for_one(type="typing") + + +@pytest.mark.agent_test(PYTHON_SCENARIO) +class TestQuickstartPython(BaseTestQuickstart): + pass + +@pytest.mark.agent_test(NET_SCENARIO) +class TestQuickstartNet(BaseTestQuickstart): + pass + +@pytest.mark.agent_test(JS_SCENARIO) +class TestQuickstartJS(BaseTestQuickstart): + pass \ No newline at end of file diff --git a/dev/tests/agents/basic_agent/python/src/__init__.py b/dev/testing/cross-sdk-tests/tests/core/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/__init__.py rename to dev/testing/cross-sdk-tests/tests/core/__init__.py diff --git a/dev/tests/integration/basic_agent/test_basic_agent_base.py b/dev/testing/cross-sdk-tests/tests/core/test_basic_agent_base.py similarity index 100% rename from dev/tests/integration/basic_agent/test_basic_agent_base.py rename to dev/testing/cross-sdk-tests/tests/core/test_basic_agent_base.py diff --git a/dev/tests/integration/basic_agent/test_directline.py b/dev/testing/cross-sdk-tests/tests/core/test_directline.py similarity index 100% rename from dev/tests/integration/basic_agent/test_directline.py rename to dev/testing/cross-sdk-tests/tests/core/test_directline.py diff --git a/dev/tests/integration/basic_agent/test_msteams.py b/dev/testing/cross-sdk-tests/tests/core/test_msteams.py similarity index 100% rename from dev/tests/integration/basic_agent/test_msteams.py rename to dev/testing/cross-sdk-tests/tests/core/test_msteams.py diff --git a/dev/tests/integration/basic_agent/test_webchat.py b/dev/testing/cross-sdk-tests/tests/core/test_webchat.py similarity index 100% rename from dev/tests/integration/basic_agent/test_webchat.py rename to dev/testing/cross-sdk-tests/tests/core/test_webchat.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/__init__.py b/dev/testing/cross-sdk-tests/tests/telemetry/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/__init__.py rename to dev/testing/cross-sdk-tests/tests/telemetry/__init__.py diff --git a/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py b/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py new file mode 100644 index 00000000..f4f2b5ac --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py @@ -0,0 +1,28 @@ +import pytest + +from tests._common import ( + create_scenario, + SDKVersion, +) + +AGENT_NAME = "quickstart" +PYTHON_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.PYTHON) +NET_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.NET) +JS_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.JS) + +class BaseTelemetryTests: + def test_telemetry(self, agent_client): + # This is a placeholder test. The actual telemetry tests will be implemented here. + assert True + +@pytest.mark.agent_test(PYTHON_SCENARIO) +class TestPythonTelemetry(BaseTelemetryTests): + pass + +@pytest.mark.agent_test(NET_SCENARIO) +class TestNetTelemetry(BaseTelemetryTests): + pass + +@pytest.mark.agent_test(JS_SCENARIO) +class TestJSTelemetry(BaseTelemetryTests): + pass \ No newline at end of file diff --git a/dev/testing/microsoft-agents-testing/README.md b/dev/testing/microsoft-agents-testing/README.md new file mode 100644 index 00000000..4acca0c5 --- /dev/null +++ b/dev/testing/microsoft-agents-testing/README.md @@ -0,0 +1,190 @@ +# Microsoft Agents Testing Framework + +A testing framework for M365 Agents that handles auth, callback servers, +activity construction, and response collection so your tests can focus on +what the agent actually does. + +```python +async with scenario.client() as client: + await client.send("Hello!", wait=0.2) + client.expect().that_for_any(text="Echo: Hello!") +``` + +## Installation + +```bash +pip install -e ./microsoft-agents-testing/ --config-settings editable_mode=compat +``` + +## Quick Start + +Define your agent, create a scenario, and write tests. The scenario takes +care of hosting, auth tokens, and response plumbing. + +### Pytest + +```python +import pytest +from microsoft_agents.testing import AiohttpScenario, AgentEnvironment + +async def init_echo(env: AgentEnvironment): + @env.agent_application.activity("message") + async def on_message(context, state): + await context.send_activity(f"Echo: {context.activity.text}") + +scenario = AiohttpScenario(init_agent=init_echo, use_jwt_middleware=False) + +@pytest.mark.agent_test(scenario) +class TestEcho: + async def test_responds(self, agent_client): + await agent_client.send("Hi!", wait=0.2) + agent_client.expect().that_for_any(text="Echo: Hi!") +``` + +```bash +pytest test_echo.py -v +``` + +### Without pytest + +The core has no pytest dependency — use `scenario.client()` as an async +context manager anywhere. + +```python +async with scenario.client() as client: + await client.send("Hi!", wait=0.2) + client.expect().that_for_any(text="Echo: Hi!") +``` + +### External agent + +To test an agent that's already running (locally or deployed), point +`ExternalScenario` at its endpoint. + +```python +from microsoft_agents.testing import ExternalScenario + +scenario = ExternalScenario("http://localhost:3978/api/messages") +async with scenario.client() as client: + await client.send("Hello!", wait=1.0) + client.expect().that_for_any(type="message") +``` + +## Scenarios + +A Scenario manages infrastructure (servers, auth, teardown) and gives you a +client to interact with the agent. Auth credentials and general SDK config settings come from a `.env` file. The path defaults to `.\.env` but this is configurable through `ScenarioConfig` and `ClientConfig`, which are passed in during `Scenario` and `AgentClient` constructions. + +| Scenario | Description | +|----------|-------------| +| `AiohttpScenario` | Hosts the agent in-process — fast, access to internals | +| `ExternalScenario` | Connects to a running agent at a URL | + +Swap one for the other and your assertions stay the same. + +## AgentClient + +The client you get from a scenario. Send messages, collect replies, make +assertions. Pass a string and it becomes a message `Activity` automatically. +Use `wait=` to pause for async callback responses, or use +`send_expect_replies()` when the agent replies inline. + +```python +await client.send("Hello!", wait=0.5) # send + wait for callbacks +replies = await client.send_expect_replies("Hi!") # inline replies +client.expect().that_for_any(text="~Hello") # assert +``` + +Every method has an `ex_` variant (`ex_send`, `ex_invoke`, etc.) that returns +the raw `Exchange` objects instead of just the response activities. + +## Expect & Select + +Fluent API for asserting on and filtering response collections. `Expect` +raises `AssertionError` with diagnostic context — it shows what was expected, +what was received, and which items were checked. Prefix a value with `~` for +substring matching, or pass a lambda for custom logic. The variable named `x` has a special meaning and is passed in dynamically during evaluation. + +```python +client.expect().that_for_any(text="~hello") # any reply contains "hello" +client.expect().that_for_none(text="~error") # no reply contains "error" +client.expect().that_for_exactly(2, type="message") # exactly 2 messages +client.expect().that_for_any(text=lambda x: len(x) > 10) # lambda predicate +``` + +`Select` filters and slices before you assert or extract: + +```python +from microsoft_agents.testing import Select +selected = Select(client.history()).where(type="message").last(3).get() +Select(client.history()).where(type="message").expect().that(text="~hello") +``` + +## Transcript + +Every request and response is recorded in a `Transcript`. When a test fails +you can print the conversation to see exactly what happened. + +`ConversationTranscriptFormatter` gives a chat-style view; +`ActivityTranscriptFormatter` shows all activities with selectable fields. +Both support `DetailLevel` (`MINIMAL`, `STANDARD`, `DETAILED`, `FULL`) and +`TimeFormat` (`CLOCK`, `RELATIVE`, `ELAPSED`). + +```python +from microsoft_agents.testing import ConversationTranscriptFormatter, DetailLevel + +ConversationTranscriptFormatter(detail=DetailLevel.FULL).print(client.transcript) +``` + +``` +[0.000s] You: Hello! + (253ms) +[0.253s] Agent: Echo: Hello! +``` + +## Pytest Plugin + +The plugin activates automatically on install. Decorate a class or function +with `@pytest.mark.agent_test(scenario)` — pass a `Scenario` instance, a URL +(creates `ExternalScenario`), or a registered scenario name — and request any +of these fixtures: + +| Fixture | Description | +|---------|-------------| +| `agent_client` | Send and assert | +| `agent_environment` | Agent internals (in-process only) | +| `agent_application` | `AgentApplication` instance | +| `storage` | `MemoryStorage` | +| `adapter` | `ChannelServiceAdapter` | +| `authorization` | `Authorization` handler | +| `connection_manager` | `Connections` manager | + +## Scenario Registry + +Register named scenarios so they can be shared across test files and +referenced by name in pytest markers. Use dot-notation for namespacing +(e.g., `"local.echo"`, `"staging.echo"`) and `discover()` with glob patterns +to find them. + +```python +from microsoft_agents.testing import scenario_registry + +scenario_registry.register("echo", echo_scenario) +scenario = scenario_registry.get("echo") + +# In a test — just pass the name +@pytest.mark.agent_test("echo") +class TestEcho: ... +``` + +## Documentation + +| Document | Contents | +|----------|----------| +| [MOTIVATION.md](MOTIVATION.md) | Before/after code comparison | +| [API.md](API.md) | Public API reference | +| [SAMPLES.md](SAMPLES.md) | Guide to the runnable samples | + +## License + +MIT License — Microsoft Corporation diff --git a/dev/microsoft-agents-testing/docs/API.md b/dev/testing/microsoft-agents-testing/docs/API.md similarity index 100% rename from dev/microsoft-agents-testing/docs/API.md rename to dev/testing/microsoft-agents-testing/docs/API.md diff --git a/dev/microsoft-agents-testing/docs/MOTIVATION.md b/dev/testing/microsoft-agents-testing/docs/MOTIVATION.md similarity index 100% rename from dev/microsoft-agents-testing/docs/MOTIVATION.md rename to dev/testing/microsoft-agents-testing/docs/MOTIVATION.md diff --git a/dev/microsoft-agents-testing/docs/README.md b/dev/testing/microsoft-agents-testing/docs/README.md similarity index 100% rename from dev/microsoft-agents-testing/docs/README.md rename to dev/testing/microsoft-agents-testing/docs/README.md diff --git a/dev/microsoft-agents-testing/docs/SAMPLES.md b/dev/testing/microsoft-agents-testing/docs/SAMPLES.md similarity index 100% rename from dev/microsoft-agents-testing/docs/SAMPLES.md rename to dev/testing/microsoft-agents-testing/docs/SAMPLES.md diff --git a/dev/microsoft-agents-testing/docs/samples/__init__.py b/dev/testing/microsoft-agents-testing/docs/samples/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/__init__.py rename to dev/testing/microsoft-agents-testing/docs/samples/__init__.py diff --git a/dev/microsoft-agents-testing/docs/samples/interactive.py b/dev/testing/microsoft-agents-testing/docs/samples/interactive.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/interactive.py rename to dev/testing/microsoft-agents-testing/docs/samples/interactive.py diff --git a/dev/microsoft-agents-testing/docs/samples/multi_client.py b/dev/testing/microsoft-agents-testing/docs/samples/multi_client.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/multi_client.py rename to dev/testing/microsoft-agents-testing/docs/samples/multi_client.py diff --git a/dev/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py b/dev/testing/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py rename to dev/testing/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py diff --git a/dev/microsoft-agents-testing/docs/samples/quickstart.py b/dev/testing/microsoft-agents-testing/docs/samples/quickstart.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/quickstart.py rename to dev/testing/microsoft-agents-testing/docs/samples/quickstart.py diff --git a/dev/microsoft-agents-testing/docs/samples/scenario_registry_demo.py b/dev/testing/microsoft-agents-testing/docs/samples/scenario_registry_demo.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/scenario_registry_demo.py rename to dev/testing/microsoft-agents-testing/docs/samples/scenario_registry_demo.py diff --git a/dev/microsoft-agents-testing/docs/samples/test_motivation_assertions.py b/dev/testing/microsoft-agents-testing/docs/samples/test_motivation_assertions.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/test_motivation_assertions.py rename to dev/testing/microsoft-agents-testing/docs/samples/test_motivation_assertions.py diff --git a/dev/microsoft-agents-testing/docs/samples/transcript_formatting.py b/dev/testing/microsoft-agents-testing/docs/samples/transcript_formatting.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/transcript_formatting.py rename to dev/testing/microsoft-agents-testing/docs/samples/transcript_formatting.py diff --git a/dev/testing/microsoft-agents-testing/env.TEMPLATE b/dev/testing/microsoft-agents-testing/env.TEMPLATE new file mode 100644 index 00000000..df82361b --- /dev/null +++ b/dev/testing/microsoft-agents-testing/env.TEMPLATE @@ -0,0 +1,5 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py similarity index 97% rename from dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py index aa148e34..8fb57d7c 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py @@ -113,7 +113,7 @@ async def _init_agent_environment(self) -> dict: :return: The SDK configuration dictionary. """ - env_vars = dotenv_values(self._config.env_file_path) + env_vars = dotenv_values(self._config.env_file_path or ".env") sdk_config = load_configuration_from_env(env_vars) storage = MemoryStorage() @@ -179,10 +179,10 @@ async def run(self) -> AsyncIterator[ClientFactory]: async with callback_server.listen() as transcript: async with TestServer(app, port=3978) as server: - agent_url = f"http://{server.host}:{server.port}/" + agent_endpoint = f"http://127.0.0.1:{server.port}/api/messages" factory = _AiohttpClientFactory( - agent_url=agent_url, + agent_endpoint=agent_endpoint, response_endpoint=callback_server.service_endpoint, sdk_config=sdk_config, default_config=self._config.client_config, diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/main.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/main.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/main.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/main.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py similarity index 94% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py index 51a0a5e4..db2276cf 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py @@ -32,14 +32,14 @@ class _AiohttpClientFactory: def __init__( self, - agent_url: str, + agent_endpoint: str, response_endpoint: str, sdk_config: dict, default_template: ActivityTemplate | None = None, default_config: ClientConfig | None = None, transcript: Transcript | None = None, ): - self._agent_url = agent_url + self._agent_endpoint = agent_endpoint self._response_endpoint = response_endpoint self._sdk_config = sdk_config self._default_template = default_template or ActivityTemplate() @@ -66,7 +66,7 @@ async def __call__(self, config: ClientConfig | None = None) -> AgentClient: pass # No auth available # Create session - session = ClientSession(base_url=self._agent_url, headers=headers) + session = ClientSession(headers=headers) self._sessions.append(session) # Build activity template with user identity @@ -76,7 +76,7 @@ async def __call__(self, config: ClientConfig | None = None) -> AgentClient: ) # Create sender and client - sender = AiohttpSender(session) + sender = AiohttpSender(self._agent_endpoint, session) return AgentClient(sender, self._transcript, template=template) async def cleanup(self): diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/config.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/config.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/config.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/config.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py similarity index 93% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py index e384a393..a20a2fcc 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py @@ -35,7 +35,7 @@ class ExternalScenario(Scenario): async with scenario.client() as client: replies = await client.send("Hello!") - :param endpoint: The URL of the agent's message endpoint. + :param agent_url: The URL of the agent's message endpoint. :param config: Optional scenario configuration. """ @@ -48,8 +48,8 @@ def __init__(self, endpoint: str, config: ScenarioConfig | None = None) -> None: @asynccontextmanager async def run(self) -> AsyncIterator[ClientFactory]: """Start callback server and yield a client factory.""" - - env_vars = dotenv_values(self._config.env_file_path) + + env_vars = dotenv_values(self._config.env_file_path or ".env") sdk_config = load_configuration_from_env(env_vars) callback_server = AiohttpCallbackServer(self._config.callback_server_port) @@ -58,7 +58,7 @@ async def run(self) -> AsyncIterator[ClientFactory]: # Create a factory that binds the agent URL, callback endpoint, # and SDK config so callers can create configured clients factory = _AiohttpClientFactory( - agent_url=self._endpoint, + agent_endpoint=self._endpoint, response_endpoint=callback_server.service_endpoint, sdk_config=sdk_config, default_config=self._config.client_config, diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py similarity index 88% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py index 581e7434..af22430b 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py @@ -10,6 +10,7 @@ from __future__ import annotations from copy import deepcopy +from email.mime import base from typing import Generic, TypeVar, cast, Self from pydantic import BaseModel @@ -22,7 +23,7 @@ set_defaults, flatten, ) -from .utils import flatten_model_data +from .utils import flatten_model_data, rename_from_property ModelT = TypeVar("ModelT", bound=BaseModel | dict) @@ -82,15 +83,17 @@ def with_defaults(self, defaults: dict | None = None, **kwargs) -> ModelTemplate :return: A new ModelTemplate instance. """ new_template = deepcopy(self._defaults) - set_defaults(new_template, defaults, **kwargs) + defaults_copy = deepcopy(defaults) if defaults else {} + rename_from_property(defaults_copy) + set_defaults(new_template, defaults_copy, **kwargs) return ModelTemplate[ModelT](self._model_class, new_template) def with_updates(self, updates: dict | None = None, **kwargs) -> ModelTemplate[ModelT]: """Create a new ModelTemplate with updated default values.""" new_template = deepcopy(self._defaults) # Expand the updates first so they merge correctly with nested structure - flat_updates = flatten(updates or {}) - flat_kwargs = flatten(kwargs) + flat_updates = flatten_model_data(updates or {}) + flat_kwargs = flatten_model_data(kwargs) deep_update(new_template, flat_updates) deep_update(new_template, flat_kwargs) # Pass already-expanded data, avoid re-expansion @@ -128,6 +131,7 @@ def __init__(self, defaults: Activity | dict | None = None, **kwargs) -> None: :param kwargs: Additional default values as keyword arguments. """ super().__init__(Activity, defaults, **kwargs) + rename_from_property(self._defaults) def with_defaults(self, defaults: dict | None = None, **kwargs) -> ActivityTemplate: """Create a new ModelTemplate with additional default values. @@ -137,16 +141,18 @@ def with_defaults(self, defaults: dict | None = None, **kwargs) -> ActivityTempl :return: A new ModelTemplate instance. """ new_template = deepcopy(self._defaults) - set_defaults(new_template, defaults, **kwargs) + defaults_copy = deepcopy(defaults) if defaults else {} + rename_from_property(defaults_copy) + set_defaults(new_template, defaults_copy, **kwargs) return ActivityTemplate(new_template) def with_updates(self, updates: dict | None = None, **kwargs) -> ActivityTemplate: """Create a new ModelTemplate with updated default values.""" new_template = deepcopy(self._defaults) # Expand the updates first so they merge correctly with nested structure - flat_updates = flatten(updates or {}) - flat_kwargs = flatten(kwargs) + flat_updates = flatten_model_data(updates or {}) + flat_kwargs = flatten_model_data(kwargs) deep_update(new_template, flat_updates) deep_update(new_template, flat_kwargs) # Pass already-expanded data, avoid re-expansion - return ActivityTemplate(new_template) + return ActivityTemplate(new_template) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py similarity index 68% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py index 91d19376..aab30872 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py @@ -11,6 +11,20 @@ from pydantic import BaseModel from .backend import expand, flatten +def rename_from_property(data: dict) -> None: + """Rename keys starting with 'from.' to 'from_property.' for compatibility.""" + mods = {} + for key in data.keys(): + if key.startswith("from."): + new_key = key.replace("from.", "from_property.") + mods[key] = new_key + elif key == "from": + new_key = "from_property" + mods[key] = new_key + + for old_key, new_key in mods.items(): + data[new_key] = data.pop(old_key) + def normalize_model_data(source: BaseModel | dict) -> dict: """Normalize a BaseModel or dictionary to an expanded dictionary. @@ -25,7 +39,9 @@ def normalize_model_data(source: BaseModel | dict) -> dict: source = cast(dict, source.model_dump(exclude_unset=True, mode="json")) return source - return expand(source) + expanded = expand(source) + rename_from_property(expanded) + return expanded def flatten_model_data(source: BaseModel | dict) -> dict: """Flatten model data to a single-level dictionary with dot-notation keys. @@ -41,4 +57,6 @@ def flatten_model_data(source: BaseModel | dict) -> dict: source = cast(dict, source.model_dump(exclude_unset=True, mode="json")) return flatten(source) - return flatten(source) \ No newline at end of file + flattened = flatten(source) + rename_from_property(flattened) + return flattened \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py similarity index 78% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py index 6e38efc7..f2527f2f 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py @@ -10,6 +10,7 @@ from typing import AsyncContextManager from aiohttp import ClientSession +from aiohttp.client_exceptions import ClientError from microsoft_agents.activity import Activity @@ -24,9 +25,14 @@ class AiohttpSender(Sender): the response in an Exchange object. """ - def __init__(self, session: ClientSession): + def __init__(self, endpoint, session: ClientSession): + self._endpoint = endpoint self._session = session + @property + def endpoint(self) -> str: + return self._endpoint + async def send(self, activity: Activity, transcript: Transcript | None = None, **kwargs) -> Exchange: """Send an activity and return the Exchange containing the response. @@ -41,7 +47,7 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * request_at = datetime.now(timezone.utc) try: async with self._session.post( - "api/messages", + self._endpoint, json=activity.model_dump( by_alias=True, exclude_unset=True, exclude_none=True, mode="json" ), @@ -49,17 +55,26 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * ) as response: response_at = datetime.now(timezone.utc) response_or_exception = response + + if response.status >= 300: + raise ClientError(f"Received non-success status code: {response.status}") + exchange = await Exchange.from_request( request_activity=activity, response_or_exception=response_or_exception, request_at=request_at, response_at=response_at, + status=response.status, **kwargs ) - except Exception as e: + except ClientError as e: + + if response_or_exception is not None: + raise # If we got a response but it was an error status, re-raise the exception + response_at = datetime.now(timezone.utc) - response_or_exception = e + response_or_exception = e exchange = await Exchange.from_request( request_activity=activity, diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py similarity index 95% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py index 3d98f197..6f12b7a3 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py @@ -89,6 +89,7 @@ def is_allowed_exception(exception: Exception) -> bool: async def from_request( request_activity: Activity, response_or_exception: Exception | ResponseT, + status: int | None = None, **kwargs ) -> Exchange: """Create an Exchange from a request activity and its outcome. @@ -115,6 +116,13 @@ async def from_request( error=str(response_or_exception), **kwargs, ) + elif isinstance(status, int) and status >= 300: + text = await response_or_exception.text() + return Exchange( + request=request_activity, + error=text, + **kwargs + ) if isinstance(response_or_exception, aiohttp.ClientResponse): @@ -129,7 +137,7 @@ async def from_request( body = await response.text() activity_list = json.loads(body)["activities"] activities = [ Activity.model_validate(activity) for activity in activity_list ] - + elif request_activity.type == ActivityTypes.invoke: body = await response.text() body_json = json.loads(body) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/utils.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/utils.py diff --git a/dev/microsoft-agents-testing/payload.json b/dev/testing/microsoft-agents-testing/payload.json similarity index 100% rename from dev/microsoft-agents-testing/payload.json rename to dev/testing/microsoft-agents-testing/payload.json diff --git a/dev/microsoft-agents-testing/pyproject.toml b/dev/testing/microsoft-agents-testing/pyproject.toml similarity index 100% rename from dev/microsoft-agents-testing/pyproject.toml rename to dev/testing/microsoft-agents-testing/pyproject.toml diff --git a/dev/microsoft-agents-testing/pytest.ini b/dev/testing/microsoft-agents-testing/pytest.ini similarity index 94% rename from dev/microsoft-agents-testing/pytest.ini rename to dev/testing/microsoft-agents-testing/pytest.ini index dae63b29..997500e6 100644 --- a/dev/microsoft-agents-testing/pytest.ini +++ b/dev/testing/microsoft-agents-testing/pytest.ini @@ -41,4 +41,5 @@ markers = slow: Slow tests that may take longer to run requires_network: Tests that require network access requires_auth: Tests that require authentication - failure_demo: Intentionally failing tests for assertion/transcript formatting review \ No newline at end of file + failure_demo: Intentionally failing tests for assertion/transcript formatting review + agent_test: Tests that involve agent interactions \ No newline at end of file diff --git a/dev/tests/agents/basic_agent/python/src/weather/agents/__init__.py b/dev/testing/microsoft-agents-testing/tests/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/agents/__init__.py rename to dev/testing/microsoft-agents-testing/tests/__init__.py diff --git a/dev/microsoft-agents-testing/tests/cli/test_cli_integration.py b/dev/testing/microsoft-agents-testing/tests/cli/test_cli_integration.py similarity index 100% rename from dev/microsoft-agents-testing/tests/cli/test_cli_integration.py rename to dev/testing/microsoft-agents-testing/tests/cli/test_cli_integration.py diff --git a/dev/microsoft-agents-testing/tests/cli/test_output.py b/dev/testing/microsoft-agents-testing/tests/cli/test_output.py similarity index 100% rename from dev/microsoft-agents-testing/tests/cli/test_output.py rename to dev/testing/microsoft-agents-testing/tests/cli/test_output.py diff --git a/dev/tests/integration/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/__init__.py similarity index 100% rename from dev/tests/integration/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/__init__.py diff --git a/dev/tests/integration/basic_agent/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/__init__.py similarity index 100% rename from dev/tests/integration/basic_agent/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/__init__.py diff --git a/dev/tests/sdk/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/__init__.py similarity index 100% rename from dev/tests/sdk/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py diff --git a/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_expect.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_expect.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/test_expect.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_expect.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_model_template.py similarity index 86% rename from dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_model_template.py index a002475e..a378987c 100644 --- a/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py +++ b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_model_template.py @@ -421,6 +421,88 @@ def test_dot_notation_for_conversation(self): assert activity.conversation.name == "Test Conv" +class TestActivityTemplateFromAliases: + """Tests for ActivityTemplate alias behavior between from and from_property.""" + + def test_from_dot_notation_defaults_are_normalized(self): + """Defaults using from.* are normalized to from_property.* internally.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from.id": "user123", "from.name": "Alias User"} + ) + + assert "from.id" not in template._defaults + assert "from.name" not in template._defaults + assert template._defaults["from_property.id"] == "user123" + assert template._defaults["from_property.name"] == "Alias User" + + def test_create_accepts_top_level_from_alias_in_defaults(self): + """Top-level from alias in defaults maps to Activity.from_property.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from": {"id": "user123", "name": "Alias User"}} + ) + + activity = template.create() + assert activity.from_property is not None + assert activity.from_property.id == "user123" + assert activity.from_property.name == "Alias User" + + def test_create_original_from_alias_overrides_from_property_default(self): + """create() accepts from alias and overrides from_property defaults.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from_property.id": "default-id", "from_property.name": "Default User"} + ) + + activity = template.create({"from": {"id": "override-id", "name": "Override User"}}) + assert activity.from_property is not None + assert activity.from_property.id == "override-id" + assert activity.from_property.name == "Override User" + + def test_create_original_from_property_overrides_from_dot_default(self): + """create() accepts from_property and overrides defaults authored with from.* alias.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from.id": "default-id", "from.name": "Default User"} + ) + + activity = template.create( + { + "from_property": { + "id": "override-id", + "name": "Override User", + } + } + ) + assert activity.from_property is not None + assert activity.from_property.id == "override-id" + assert activity.from_property.name == "Override User" + + def test_with_defaults_accepts_from_alias(self): + """with_defaults() supports from alias and produces from_property on create.""" + template = ActivityTemplate(type=ActivityTypes.message).with_defaults( + **{"from.id": "user123", "from.name": "Alias User"} + ) + + activity = template.create() + assert activity.from_property is not None + assert activity.from_property.id == "user123" + assert activity.from_property.name == "Alias User" + + def test_with_updates_accepts_from_alias(self): + """with_updates() supports from alias and updates existing from_property values.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from_property.id": "default-id", "from_property.name": "Default User"} + ).with_updates(**{"from.id": "updated-id", "from.name": "Updated User"}) + + activity = template.create() + assert activity.from_property is not None + assert activity.from_property.id == "updated-id" + assert activity.from_property.name == "Updated User" + + class TestActivityTemplateEquality: """Tests for ActivityTemplate equality comparison.""" diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_select.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_select.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/test_select.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_select.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_utils.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_utils.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/test_utils.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_utils.py diff --git a/dev/microsoft-agents-testing/tests/core/test_agent_client.py b/dev/testing/microsoft-agents-testing/tests/core/test_agent_client.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/test_agent_client.py rename to dev/testing/microsoft-agents-testing/tests/core/test_agent_client.py diff --git a/dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py b/dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py similarity index 94% rename from dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py rename to dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py index 80833d3c..dd31e0a1 100644 --- a/dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py +++ b/dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py @@ -30,7 +30,7 @@ def test_initialization_stores_all_parameters(self): sdk_config = {"CONNECTIONS": {}} factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config=sdk_config, default_template=template, @@ -38,7 +38,7 @@ def test_initialization_stores_all_parameters(self): transcript=transcript, ) - assert factory._agent_url == "http://localhost:3978" + assert factory._agent_endpoint == "http://localhost:3978" assert factory._response_endpoint == "http://localhost:9378/api/callback" assert factory._sdk_config is sdk_config assert factory._default_template is template @@ -48,7 +48,7 @@ def test_initialization_stores_all_parameters(self): def test_initialization_creates_empty_sessions_list(self): """Factory initializes with empty sessions list.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -60,17 +60,17 @@ def test_initialization_creates_empty_sessions_list(self): # ============================================================================ -# _AiohttpClientfactory Tests +# _AiohttpClientFactory Tests # ============================================================================ class TestAiohttpClientFactoryCreateClient: - """Tests for _AiohttpClientfactory method.""" + """Tests for _AiohttpClientFactory method.""" @pytest.fixture def factory(self): """Create a factory with default configuration.""" return _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(type="message"), @@ -210,7 +210,7 @@ class TestAiohttpClientFactoryAuthorization: async def test_explicit_authorization_header_preserved(self): """Explicit Authorization header is preserved.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -232,7 +232,7 @@ async def test_explicit_authorization_header_preserved(self): async def test_auth_token_overrides_when_no_explicit_authorization(self): """auth_token is used when no explicit Authorization header.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -254,7 +254,7 @@ async def test_auth_token_overrides_when_no_explicit_authorization(self): async def test_no_auth_when_no_token_and_no_sdk_config(self): """No Authorization header when no token and sdk_config fails.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, # Empty, will cause generate_token_from_config to fail default_template=ActivityTemplate(), @@ -278,7 +278,7 @@ async def test_sdk_config_token_generation_on_failure(self): invalid_sdk_config = {"CONNECTIONS": {"SERVICE_CONNECTION": {"SETTINGS": {}}}} factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config=invalid_sdk_config, default_template=ActivityTemplate(), @@ -306,7 +306,7 @@ class TestAiohttpClientFactoryCleanup: async def test_cleanup_closes_all_sessions(self): """cleanup closes all created sessions.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -331,7 +331,7 @@ async def test_cleanup_closes_all_sessions(self): async def test_cleanup_clears_sessions_list(self): """cleanup clears the sessions list.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -352,7 +352,7 @@ async def test_cleanup_clears_sessions_list(self): async def test_cleanup_on_empty_sessions_list(self): """cleanup handles empty sessions list gracefully.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -369,7 +369,7 @@ async def test_cleanup_on_empty_sessions_list(self): async def test_cleanup_can_be_called_multiple_times(self): """cleanup can be called multiple times safely.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -398,7 +398,7 @@ async def test_default_template_used_when_config_has_none(self): default_template = ActivityTemplate(type="message", text="Default") factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=default_template, @@ -421,7 +421,7 @@ async def test_config_template_used_when_provided(self): config = ClientConfig(activity_template=custom_template) factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=default_template, @@ -448,7 +448,7 @@ class TestAiohttpClientFactoryIntegration: async def test_full_workflow_create_and_cleanup(self): """Full workflow: create multiple clients, then cleanup.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -481,7 +481,7 @@ async def test_full_workflow_create_and_cleanup(self): async def test_session_base_url_is_set_correctly(self): """Sessions are created with correct base_url.""" factory = _AiohttpClientFactory( - agent_url="http://my-agent:3978", + agent_endpoint="http://my-agent:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -493,7 +493,6 @@ async def test_session_base_url_is_set_correctly(self): try: session = factory._sessions[0] - # aiohttp stores base_url as a URL object - assert str(session._base_url) == "http://my-agent:3978" + assert session._base_url is None finally: await factory.cleanup() diff --git a/dev/microsoft-agents-testing/tests/core/test_config.py b/dev/testing/microsoft-agents-testing/tests/core/test_config.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/test_config.py rename to dev/testing/microsoft-agents-testing/tests/core/test_config.py diff --git a/dev/microsoft-agents-testing/tests/core/test_external_scenario.py b/dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py similarity index 99% rename from dev/microsoft-agents-testing/tests/core/test_external_scenario.py rename to dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py index 85d21649..5fd29869 100644 --- a/dev/microsoft-agents-testing/tests/core/test_external_scenario.py +++ b/dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py @@ -238,7 +238,7 @@ async def test_run_passes_endpoint_to_factory(self): mock_server_class.return_value = mock_server async with scenario.run() as factory: - assert factory._agent_url == "http://my-agent:3978/api/messages" + assert factory._agent_endpoint == "http://my-agent:3978/api/messages" @pytest.mark.asyncio async def test_run_passes_service_endpoint_to_factory(self): @@ -539,7 +539,7 @@ async def test_run_with_none_env_file_path(self): mock_server_class.return_value = mock_server async with scenario.run() as factory: - mock_dotenv.assert_called_once_with(None) + mock_dotenv.assert_called_once_with(".env") # ============================================================================ diff --git a/dev/microsoft-agents-testing/tests/core/test_integration.py b/dev/testing/microsoft-agents-testing/tests/core/test_integration.py similarity index 92% rename from dev/microsoft-agents-testing/tests/core/test_integration.py rename to dev/testing/microsoft-agents-testing/tests/core/test_integration.py index b9facb30..f8133628 100644 --- a/dev/microsoft-agents-testing/tests/core/test_integration.py +++ b/dev/testing/microsoft-agents-testing/tests/core/test_integration.py @@ -169,10 +169,12 @@ async def test_sender_posts_to_real_server(self): """AiohttpSender posts to a real HTTP server.""" mock_server = MockAgentServer(port=9901) mock_server.default_response(Activity(type=ActivityTypes.message, text="Reply")) + + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -189,10 +191,11 @@ async def test_sender_with_expect_replies(self): Activity(type=ActivityTypes.message, text="Reply 1"), Activity(type=ActivityTypes.message, text="Reply 2") ) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) activity = Activity( type=ActivityTypes.message, text="Hello", @@ -210,10 +213,11 @@ async def test_sender_with_invoke(self): """AiohttpSender handles invoke activities.""" mock_server = MockAgentServer(port=9903) mock_server.on_invoke("action/test", 200, {"result": "success"}) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) activity = Activity(type=ActivityTypes.invoke, name="action/test") exchange = await sender.send(activity) @@ -226,10 +230,11 @@ async def test_sender_with_invoke(self): async def test_sender_records_to_transcript(self): """AiohttpSender records exchanges to transcript.""" mock_server = MockAgentServer(port=9904) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) transcript = Transcript() activity1 = Activity(type=ActivityTypes.message, text="First") @@ -255,10 +260,11 @@ async def test_client_sends_via_http(self): """AgentClient sends activities via real HTTP.""" mock_server = MockAgentServer(port=9905) mock_server.default_response(Activity(type=ActivityTypes.message, text="OK")) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) template = ActivityTemplate( channel_id="test", **{"conversation.id": "conv-1", "from.id": "user-1"} @@ -285,8 +291,9 @@ async def test_client_full_conversation_flow(self): mock_server.default_response(Activity(type=ActivityTypes.message, text="I don't understand")) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) # Greeting @@ -311,8 +318,9 @@ async def test_client_invoke_via_http(self): mock_server.on_invoke("submit/form", 200, {"submitted": True, "id": "form-123"}) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) invoke_response = await client.invoke( @@ -406,11 +414,12 @@ async def test_factory_creates_working_client(self): """Factory creates clients that can communicate with agent.""" mock_server = MockAgentServer(port=9911) mock_server.default_response(Activity(type=ActivityTypes.message, text="Factory test OK")) - + agent_endpoint = f"{mock_server.endpoint}/api/messages" + async with mock_server.run(): transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(channel_id="test"), @@ -440,9 +449,10 @@ async def test_factory_applies_default_template(self): ) async with mock_server.run(): + agent_endpoint = f"{mock_server.endpoint}/api/messages" transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=default_template, @@ -467,9 +477,10 @@ async def test_factory_creates_multiple_clients(self): mock_server.default_response(Activity(type=ActivityTypes.message, text="OK")) async with mock_server.run(): + agent_endpoint = f"{mock_server.endpoint}/api/messages" transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -500,8 +511,9 @@ async def test_factory_cleanup_closes_sessions(self): mock_server = MockAgentServer(port=9914) async with mock_server.run(): + agent_endpoint = f"{mock_server.endpoint}/api/messages" factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -581,8 +593,9 @@ async def test_complete_http_conversation_flow(self): async with mock_server.run(): # Setup infrastructure transcript = Transcript() + agent_endpoint = f"{mock_server.endpoint}/api/messages" factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate( @@ -633,11 +646,12 @@ async def test_multi_user_http_conversation(self): """Multiple users in same conversation via HTTP.""" mock_server = MockAgentServer(port=9921) mock_server.default_response(Activity(type=ActivityTypes.message, text="Received")) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(**{"conversation.id": "multi-user-conv"}), @@ -683,8 +697,9 @@ async def test_invoke_and_message_mixed_flow(self): mock_server.default_response(Activity(type=ActivityTypes.message, text="Message received")) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) # Regular message @@ -729,8 +744,9 @@ async def test_select_and_expect_with_http_responses(self): ) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) responses = await client.send_expect_replies("report") diff --git a/dev/microsoft-agents-testing/tests/core/transport/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/transport/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py similarity index 85% rename from dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py index c040696a..b90af39b 100644 --- a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py +++ b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py @@ -5,12 +5,13 @@ import json from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from contextlib import asynccontextmanager import pytest import aiohttp from aiohttp import ClientSession, ClientResponse +from aiohttp.client_exceptions import ClientError from microsoft_agents.activity import Activity, ActivityTypes, DeliveryModes from microsoft_agents.testing.core.transport import AiohttpSender @@ -36,6 +37,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post return mock_session +ENDPOINT = "http://localhost:9999/api/messages" class TestAiohttpSenderInitialization: """Tests for AiohttpSender initialization.""" @@ -44,7 +46,7 @@ def test_aiohttp_sender_stores_session(self): """AiohttpSender should store the provided session.""" mock_session = MagicMock(spec=ClientSession) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) assert sender._session is mock_session @@ -67,13 +69,13 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") await sender.send(activity) assert len(post_calls) == 1 - assert post_calls[0][0][0] == "api/messages" + assert post_calls[0][0][0] == "http://localhost:9999/api/messages" @pytest.mark.asyncio async def test_send_serializes_activity_correctly(self): @@ -90,7 +92,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") await sender.send(activity) @@ -107,7 +109,7 @@ async def test_send_returns_exchange(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -122,7 +124,7 @@ async def test_send_records_timestamps(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -138,7 +140,7 @@ async def test_send_records_to_transcript(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") transcript = Transcript() @@ -153,7 +155,7 @@ async def test_send_without_transcript_does_not_record(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") # Should not raise @@ -175,7 +177,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") await sender.send(activity, timeout=30) @@ -198,7 +200,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -218,7 +220,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -237,7 +239,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") transcript = Transcript() @@ -258,7 +260,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") with pytest.raises(ValueError, match="Unexpected error"): @@ -279,7 +281,7 @@ async def test_send_expect_replies_parses_responses(self): mock_response = create_mock_response(200, responses_json) mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity( type=ActivityTypes.message, text="Hello", @@ -304,7 +306,7 @@ async def test_send_invoke_parses_invoke_response(self): mock_response = create_mock_response(200, invoke_response_json) mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity( type=ActivityTypes.invoke, name="testAction" @@ -315,3 +317,15 @@ async def test_send_invoke_parses_invoke_response(self): assert exchange.invoke_response is not None assert exchange.invoke_response.status == 200 assert exchange.invoke_response.body == {"result": "success"} + + @pytest.mark.asyncio + async def test_send_raises_on_non_success_status(self): + """send should raise ClientError when response status >= 300.""" + mock_response = create_mock_response(status=404, text="Not Found") + mock_session = create_mock_session(mock_response) + + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) + activity = Activity(type=ActivityTypes.message, text="Hello") + + with pytest.raises(ClientError, match="404"): + await sender.send(activity) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/transport/transcript/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/transcript/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/transcript/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py b/dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py b/dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py diff --git a/dev/microsoft-agents-testing/tests/manual.py b/dev/testing/microsoft-agents-testing/tests/manual.py similarity index 100% rename from dev/microsoft-agents-testing/tests/manual.py rename to dev/testing/microsoft-agents-testing/tests/manual.py diff --git a/dev/microsoft-agents-testing/tests/test_aiohttp_scenario.py b/dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_aiohttp_scenario.py rename to dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario.py diff --git a/dev/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py b/dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py rename to dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py diff --git a/dev/testing/microsoft-agents-testing/tests/test_examples.py b/dev/testing/microsoft-agents-testing/tests/test_examples.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/test_pytest_plugin.py b/dev/testing/microsoft-agents-testing/tests/test_pytest_plugin.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_pytest_plugin.py rename to dev/testing/microsoft-agents-testing/tests/test_pytest_plugin.py diff --git a/dev/microsoft-agents-testing/tests/test_scenario_registry.py b/dev/testing/microsoft-agents-testing/tests/test_scenario_registry.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_scenario_registry.py rename to dev/testing/microsoft-agents-testing/tests/test_scenario_registry.py diff --git a/dev/microsoft-agents-testing/tests/test_scenario_registry_plugin.py b/dev/testing/microsoft-agents-testing/tests/test_scenario_registry_plugin.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_scenario_registry_plugin.py rename to dev/testing/microsoft-agents-testing/tests/test_scenario_registry_plugin.py diff --git a/dev/microsoft-agents-testing/tests/test_transcript_formatter.py b/dev/testing/microsoft-agents-testing/tests/test_transcript_formatter.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_transcript_formatter.py rename to dev/testing/microsoft-agents-testing/tests/test_transcript_formatter.py diff --git a/dev/testing/python-sdk-tests/README.md b/dev/testing/python-sdk-tests/README.md new file mode 100644 index 00000000..3a3a5f2f --- /dev/null +++ b/dev/testing/python-sdk-tests/README.md @@ -0,0 +1,11 @@ +# Python SDK (Integration) Tests + +## Description + +This directory contains integration tests for the Python SDK. + +## Directory structure + +`integration`: general-purpose tests that run with automated, locally-running agents +`scenarios`: agent scenarios used for testing +`sdk`: integration for specific SDK components. This directory mirrors the structure of the Python SDK libraries. \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/__init__.py b/dev/testing/python-sdk-tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/python-sdk-tests/env.TEMPLATE b/dev/testing/python-sdk-tests/env.TEMPLATE new file mode 100644 index 00000000..df82361b --- /dev/null +++ b/dev/testing/python-sdk-tests/env.TEMPLATE @@ -0,0 +1,5 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/pytest.ini b/dev/testing/python-sdk-tests/pytest.ini new file mode 100644 index 00000000..9908f4bf --- /dev/null +++ b/dev/testing/python-sdk-tests/pytest.ini @@ -0,0 +1,33 @@ +[pytest] +# Pytest configuration for Microsoft Agents for Python + +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + ignore::aiohttp.web.NotAppKeyWarning + +# Test discovery configuration +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* +asyncio_mode=auto + +# Output configuration +addopts = + --strict-markers + --strict-config + --verbose + --tb=short + --durations=10 + +# Minimum version requirement +minversion = 6.0 + +# Markers for test categorization +markers = + unit: Unit tests + integration: Integration tests + slow: Slow tests that may take longer to run + requires_network: Tests that require network access + requires_auth: Tests that require authentication \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/tests/__init__.py b/dev/testing/python-sdk-tests/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/python-sdk-tests/tests/integration/__init__.py b/dev/testing/python-sdk-tests/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/tests/sdk/test_expect_replies.py b/dev/testing/python-sdk-tests/tests/integration/test_expect_replies.py similarity index 95% rename from dev/tests/sdk/test_expect_replies.py rename to dev/testing/python-sdk-tests/tests/integration/test_expect_replies.py index 3a338f32..993e56c0 100644 --- a/dev/tests/sdk/test_expect_replies.py +++ b/dev/testing/python-sdk-tests/tests/integration/test_expect_replies.py @@ -1,7 +1,7 @@ import pytest from microsoft_agents.activity import Activity from microsoft_agents.testing import AgentClient -from ..scenarios import load_scenario +from tests.scenarios import load_scenario @pytest.mark.agent_test(load_scenario("quickstart")) diff --git a/dev/tests/integration/test_quickstart.py b/dev/testing/python-sdk-tests/tests/integration/test_quickstart.py similarity index 98% rename from dev/tests/integration/test_quickstart.py rename to dev/testing/python-sdk-tests/tests/integration/test_quickstart.py index a1053775..58e07e5d 100644 --- a/dev/tests/integration/test_quickstart.py +++ b/dev/testing/python-sdk-tests/tests/integration/test_quickstart.py @@ -1,5 +1,4 @@ import pytest -from ..scenarios import load_scenario from microsoft_agents.testing import ( ActivityTemplate, @@ -8,6 +7,8 @@ ScenarioConfig, ) +from tests.scenarios import load_scenario + _TEMPLATE = { "channel_id": "webchat", "locale": "en-US", @@ -29,6 +30,7 @@ class TestQuickstart: @pytest.mark.asyncio async def test_conversation_update(self, agent_client: AgentClient): """Test sending a conversation update activity.""" + input_activity = agent_client.template.create({ "type": "conversationUpdate", "members_added": [ diff --git a/dev/tests/scenarios/__init__.py b/dev/testing/python-sdk-tests/tests/scenarios/__init__.py similarity index 90% rename from dev/tests/scenarios/__init__.py rename to dev/testing/python-sdk-tests/tests/scenarios/__init__.py index bfd4ee47..dd9b85a0 100644 --- a/dev/tests/scenarios/__init__.py +++ b/dev/testing/python-sdk-tests/tests/scenarios/__init__.py @@ -4,7 +4,7 @@ Scenario, ) -from .quickstart import init_app as init_quickstart +from .quickstart import init_agent as init_quickstart _SCENARIO_INITS = { "quickstart": init_quickstart, diff --git a/dev/tests/scenarios/quickstart.py b/dev/testing/python-sdk-tests/tests/scenarios/quickstart.py similarity index 93% rename from dev/tests/scenarios/quickstart.py rename to dev/testing/python-sdk-tests/tests/scenarios/quickstart.py index 2f8e0a7a..a0f0375c 100644 --- a/dev/tests/scenarios/quickstart.py +++ b/dev/testing/python-sdk-tests/tests/scenarios/quickstart.py @@ -10,9 +10,11 @@ TurnState ) -from microsoft_agents.testing import AgentEnvironment +from microsoft_agents.testing import ( + AgentEnvironment, +) -async def init_app(env: AgentEnvironment): +async def init_agent(env: AgentEnvironment): """Initialize the application for the quickstart sample.""" app: AgentApplication[TurnState] = env.agent_application diff --git a/dev/testing/python-sdk-tests/tests/sdk/__init__.py b/dev/testing/python-sdk-tests/tests/sdk/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/__init__.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/__init__.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py new file mode 100644 index 00000000..695c4fb9 --- /dev/null +++ b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py @@ -0,0 +1,139 @@ +import pytest + +from opentelemetry import trace, metrics +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from microsoft_agents.hosting.core.telemetry import constants + +from tests.scenarios import load_scenario + +_SCENARIO = load_scenario("quickstart", use_jwt_middleware=False) + +@pytest.fixture(scope="module") +def test_telemetry(): + """Set up fresh in-memory exporter for testing.""" + exporter = InMemorySpanExporter() + metric_reader = InMemoryMetricReader() + + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + trace.set_tracer_provider(tracer_provider) + + meter_provider = MeterProvider([metric_reader]) + + metrics.set_meter_provider(meter_provider) + + yield exporter, metric_reader + + exporter.clear() + tracer_provider.shutdown() + meter_provider.shutdown() + +@pytest.fixture(scope="function") +def test_exporter(test_telemetry): + """Provide the in-memory span exporter for each test.""" + exporter, _ = test_telemetry + return exporter + +@pytest.fixture(scope="function") +def test_metric_reader(test_telemetry): + """Provide the in-memory metric reader for each test.""" + _, metric_reader = test_telemetry + return metric_reader + +@pytest.fixture(autouse=True, scope="function") +def clear(test_exporter, test_metric_reader): + """Clear spans before each test to ensure test isolation.""" + test_exporter.clear() + test_metric_reader.force_flush() + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_basic(test_exporter, agent_client): + """Test that spans are created for a simple scenario.""" + + await agent_client.send_expect_replies("Hello!") + + spans = test_exporter.get_finished_spans() + + # We should have a span for the overall turn + assert any( + span.name == constants.SPAN_APP_ON_TURN + for span in spans + ) + turn_span = next(span for span in spans if span.name == constants.SPAN_APP_ON_TURN) + assert ( + "activity.type" in turn_span.attributes and + "agent.is_agentic" in turn_span.attributes and + "from.id" in turn_span.attributes and + "recipient.id" in turn_span.attributes and + "conversation.id" in turn_span.attributes and + "channel_id" in turn_span.attributes and + "message.text.length" in turn_span.attributes + ) + assert turn_span.attributes["activity.type"] == "message" + assert turn_span.attributes["agent.is_agentic"] == False + assert turn_span.attributes["message.text.length"] == len("Hello!") + + # adapter processing is a key part of the turn, so we should have a span for it + assert any( + span.name == constants.SPAN_ADAPTER_PROCESS + for span in spans + ) + + # storage is read when accessing conversation state + assert any( + span.name == constants.SPAN_STORAGE_READ + for span in spans + ) + + assert len(spans) >= 3 + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_multiple_users(test_exporter, agent_client): + """Test that spans are created correctly for multiple users.""" + + activity1 = agent_client.template.create({ + "from.id": "user1", + "text": "Hello from user 1" + }) + + activity2 = agent_client.template.create({ + "from.id": "user2", + "text": "Hello from user 2" + }) + + await agent_client.send_expect_replies(activity1) + await agent_client.send_expect_replies(activity2) + + spans = test_exporter.get_finished_spans() + + def assert_span_for_user(user_id: str): + assert any( + span.name == constants.SPAN_APP_ON_TURN and span.attributes.get("from.id") == user_id + for span in spans + ) + + assert_span_for_user("user1") + assert_span_for_user("user2") + + assert len(list(filter(lambda span: span.name == constants.SPAN_APP_ON_TURN, spans))) == 2 + assert len(list(filter(lambda span: span.name == constants.SPAN_ADAPTER_PROCESS, spans))) == 2 + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_metrics(test_metric_reader, agent_client): + """Test that metrics are recorded for a simple scenario.""" + + await agent_client.send_expect_replies("Hello!") + + metrics_data = test_metric_reader.get_metrics_data() + + metrics = metrics_data.resource_metrics + + assert len(metrics) > 0 \ No newline at end of file diff --git a/dev/tests/agents/__init__.py b/dev/tests/agents/__init__.py deleted file mode 100644 index 8b5f94fa..00000000 --- a/dev/tests/agents/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .basic_agent \ No newline at end of file diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py new file mode 100644 index 00000000..560908aa --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agents_model import AgentsModel + +from ._type_aliases import NonEmptyString + + +class TokenExchangeResource(AgentsModel): + """ + A type containing information for token exchange. + """ + + uri: NonEmptyString = None + token: NonEmptyString = None diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 8c46a7f9..b509ed0a 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -29,6 +29,7 @@ AgentAuthConfiguration, ) from microsoft_agents.authentication.msal.errors import authentication_errors +from .telemetry import spans logger = logging.getLogger(__name__) @@ -68,40 +69,48 @@ def __init__(self, msal_configuration: AgentAuthConfiguration): async def get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False ) -> str: - logger.debug( - f"Requesting access token for resource: {resource_url}, scopes: {scopes}" - ) - valid_uri, instance_uri = self._uri_validator(resource_url) - if not valid_uri: - raise ValueError(str(authentication_errors.InvalidInstanceUrl)) - assert instance_uri is not None # for mypy - - local_scopes = self._resolve_scopes_list(instance_uri, scopes) - msal_auth_client = self._get_client() - - if isinstance(msal_auth_client, ManagedIdentityClient): - logger.info("Acquiring token using Managed Identity Client.") - auth_result_payload = await _async_acquire_token_for_client( - msal_auth_client, resource=resource_url - ) - elif isinstance(msal_auth_client, ConfidentialClientApplication): - logger.info("Acquiring token using Confidential Client Application.") - auth_result_payload = await _async_acquire_token_for_client( - msal_auth_client, scopes=local_scopes + with spans.start_span_auth_get_access_token( + scopes, + self._msal_configuration.AUTH_TYPE, + ): + logger.debug( + f"Requesting access token for resource: {resource_url}, scopes: {scopes}" ) - else: - auth_result_payload = None - - res = auth_result_payload.get("access_token") if auth_result_payload else None - if not res: - logger.error("Failed to acquire token for resource %s", auth_result_payload) - raise ValueError( - authentication_errors.FailedToAcquireToken.format( - str(auth_result_payload) + valid_uri, instance_uri = self._uri_validator(resource_url) + if not valid_uri: + raise ValueError(str(authentication_errors.InvalidInstanceUrl)) + assert instance_uri is not None # for mypy + + local_scopes = self._resolve_scopes_list(instance_uri, scopes) + msal_auth_client = self._get_client() + + if isinstance(msal_auth_client, ManagedIdentityClient): + logger.info("Acquiring token using Managed Identity Client.") + auth_result_payload = await _async_acquire_token_for_client( + msal_auth_client, resource=resource_url + ) + elif isinstance(msal_auth_client, ConfidentialClientApplication): + logger.info("Acquiring token using Confidential Client Application.") + auth_result_payload = await _async_acquire_token_for_client( + msal_auth_client, scopes=local_scopes ) + else: + auth_result_payload = None + + res = ( + auth_result_payload.get("access_token") if auth_result_payload else None ) + if not res: + logger.error( + "Failed to acquire token for resource %s", auth_result_payload + ) + raise ValueError( + authentication_errors.FailedToAcquireToken.format( + str(auth_result_payload) + ) + ) - return res + return res async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str @@ -112,44 +121,44 @@ async def acquire_token_on_behalf_of( :param user_assertion: The user assertion token. :return: The access token as a string. """ - - msal_auth_client = self._get_client() - if isinstance(msal_auth_client, ManagedIdentityClient): - logger.error( - "Attempted on-behalf-of flow with Managed Identity authentication." - ) - raise NotImplementedError( - str(authentication_errors.OnBehalfOfFlowNotSupportedManagedIdentity) - ) - elif isinstance(msal_auth_client, ConfidentialClientApplication): - # TODO: Handling token error / acquisition failed - - # MSAL in Python does not support async, so we use asyncio.to_thread to run it in - # a separate thread and avoid blocking the event loop - token = await asyncio.to_thread( - lambda: msal_auth_client.acquire_token_on_behalf_of( - scopes=scopes, user_assertion=user_assertion - ) - ) - - if "access_token" not in token: + with spans.start_span_auth_acquire_token_on_behalf_of(scopes): + msal_auth_client = self._get_client() + if isinstance(msal_auth_client, ManagedIdentityClient): logger.error( - f"Failed to acquire token on behalf of user: {user_assertion}" + "Attempted on-behalf-of flow with Managed Identity authentication." ) - raise ValueError( - authentication_errors.FailedToAcquireToken.format(str(token)) + raise NotImplementedError( + str(authentication_errors.OnBehalfOfFlowNotSupportedManagedIdentity) ) + elif isinstance(msal_auth_client, ConfidentialClientApplication): + # TODO: Handling token error / acquisition failed + + # MSAL in Python does not support async, so we use asyncio.to_thread to run it in + # a separate thread and avoid blocking the event loop + token = await asyncio.to_thread( + lambda: msal_auth_client.acquire_token_on_behalf_of( + scopes=scopes, user_assertion=user_assertion + ) + ) + + if "access_token" not in token: + logger.error( + f"Failed to acquire token on behalf of user: {user_assertion}" + ) + raise ValueError( + authentication_errors.FailedToAcquireToken.format(str(token)) + ) - return token["access_token"] + return token["access_token"] - logger.error( - f"On-behalf-of flow is not supported with the current authentication type: {msal_auth_client.__class__.__name__}" - ) - raise NotImplementedError( - authentication_errors.OnBehalfOfFlowNotSupportedAuthType.format( - msal_auth_client.__class__.__name__ + logger.error( + f"On-behalf-of flow is not supported with the current authentication type: {msal_auth_client.__class__.__name__}" + ) + raise NotImplementedError( + authentication_errors.OnBehalfOfFlowNotSupportedAuthType.format( + msal_auth_client.__class__.__name__ + ) ) - ) @staticmethod def _resolve_authority( @@ -339,78 +348,80 @@ async def get_agentic_instance_token( :return: A tuple containing the agentic instance token and the agent application token. :rtype: tuple[str, str] """ + with spans.start_span_auth_get_agentic_instance_token(agent_app_instance_id): - if not agent_app_instance_id: - raise ValueError( - str(authentication_errors.AgentApplicationInstanceIdRequired) - ) - - logger.info( - "Attempting to get agentic instance token from agent_app_instance_id %s", - agent_app_instance_id, - ) - agent_token_result = await self.get_agentic_application_token( - tenant_id, agent_app_instance_id - ) + if not agent_app_instance_id: + raise ValueError( + str(authentication_errors.AgentApplicationInstanceIdRequired) + ) - if not agent_token_result: - logger.error( - "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + logger.info( + "Attempting to get agentic instance token from agent_app_instance_id %s", agent_app_instance_id, ) - raise Exception( - authentication_errors.FailedToAcquireAgenticInstanceToken.format( - agent_app_instance_id - ) + agent_token_result = await self.get_agentic_application_token( + tenant_id, agent_app_instance_id ) - authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) + if not agent_token_result: + logger.error( + "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + agent_app_instance_id, + ) + raise Exception( + authentication_errors.FailedToAcquireAgenticInstanceToken.format( + agent_app_instance_id + ) + ) - instance_app = ConfidentialClientApplication( - client_id=agent_app_instance_id, - authority=authority, - client_credential={"client_assertion": agent_token_result}, - # token_cache=self._token_cache, - ) + authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) - agentic_instance_token = await _async_acquire_token_for_client( - instance_app, ["api://AzureAdTokenExchange/.default"] - ) + instance_app = ConfidentialClientApplication( + client_id=agent_app_instance_id, + authority=authority, + client_credential={"client_assertion": agent_token_result}, + # token_cache=self._token_cache, + ) - if not agentic_instance_token: - logger.error( - "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", - agent_app_instance_id, + agentic_instance_token = await _async_acquire_token_for_client( + instance_app, ["api://AzureAdTokenExchange/.default"] ) - raise Exception( - authentication_errors.FailedToAcquireAgenticInstanceToken.format( - agent_app_instance_id + + if not agentic_instance_token: + logger.error( + "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + agent_app_instance_id, + ) + raise Exception( + authentication_errors.FailedToAcquireAgenticInstanceToken.format( + agent_app_instance_id + ) ) - ) - # future scenario where we don't know the blueprint id upfront + # future scenario where we don't know the blueprint id upfront - token = agentic_instance_token.get("access_token") - if not token: - logger.error( - "Failed to acquire agentic instance token, %s", agentic_instance_token - ) - raise ValueError( - authentication_errors.FailedToAcquireToken.format( - str(agentic_instance_token) + token = agentic_instance_token.get("access_token") + if not token: + logger.error( + "Failed to acquire agentic instance token, %s", + agentic_instance_token, ) - ) - - logger.debug( - "Agentic blueprint id: %s", - _DeferredString( - lambda: jwt.decode(token, options={"verify_signature": False}).get( - "xms_par_app_azp" + raise ValueError( + authentication_errors.FailedToAcquireToken.format( + str(agentic_instance_token) + ) ) - ), - ) - return agentic_instance_token["access_token"], agent_token_result + logger.debug( + "Agentic blueprint id: %s", + _DeferredString( + lambda: jwt.decode(token, options={"verify_signature": False}).get( + "xms_par_app_azp" + ) + ), + ) + + return agentic_instance_token["access_token"], agent_token_result async def get_agentic_user_token( self, @@ -430,76 +441,81 @@ async def get_agentic_user_token( :return: The agentic user token, or None if not found. :rtype: Optional[str] """ - if not agent_app_instance_id or not agentic_user_id: - raise ValueError( - str(authentication_errors.AgentApplicationInstanceIdAndUserIdRequired) - ) - - logger.info( - "Attempting to get agentic user token from agent_app_instance_id %s and agentic_user_id %s", - agent_app_instance_id, - agentic_user_id, - ) - instance_token, agent_token = await self.get_agentic_instance_token( - tenant_id, agent_app_instance_id - ) + with spans.start_span_get_agentic_user_token( + agent_app_instance_id, agentic_user_id, scopes + ): + if not agent_app_instance_id or not agentic_user_id: + raise ValueError( + str( + authentication_errors.AgentApplicationInstanceIdAndUserIdRequired + ) + ) - if not instance_token or not agent_token: - logger.error( - "Failed to acquire instance token or agent token for agent_app_instance_id %s and agentic_user_id %s", + logger.info( + "Attempting to get agentic user token from agent_app_instance_id %s and agentic_user_id %s", agent_app_instance_id, agentic_user_id, ) - raise Exception( - authentication_errors.FailedToAcquireInstanceOrAgentToken.format( - agent_app_instance_id, agentic_user_id - ) + instance_token, agent_token = await self.get_agentic_instance_token( + tenant_id, agent_app_instance_id ) - authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) - - instance_app = ConfidentialClientApplication( - client_id=agent_app_instance_id, - authority=authority, - client_credential={"client_assertion": agent_token}, - # token_cache=self._token_cache, - ) + if not instance_token or not agent_token: + logger.error( + "Failed to acquire instance token or agent token for agent_app_instance_id %s and agentic_user_id %s", + agent_app_instance_id, + agentic_user_id, + ) + raise Exception( + authentication_errors.FailedToAcquireInstanceOrAgentToken.format( + agent_app_instance_id, agentic_user_id + ) + ) - logger.info( - "Acquiring agentic user token for agent_app_instance_id %s and agentic_user_id %s", - agent_app_instance_id, - agentic_user_id, - ) - # MSAL in Python does not support async, so we use asyncio.to_thread to run it in - # a separate thread and avoid blocking the event loop - auth_result_payload = await _async_acquire_token_for_client( - instance_app, - scopes, - data={ - "user_id": agentic_user_id, - "user_federated_identity_credential": instance_token, - "grant_type": "user_fic", - }, - ) + authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) - if not auth_result_payload: - logger.error( - "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", - agent_app_instance_id, - agentic_user_id, - auth_result_payload, + instance_app = ConfidentialClientApplication( + client_id=agent_app_instance_id, + authority=authority, + client_credential={"client_assertion": agent_token}, + # token_cache=self._token_cache, ) - return None - access_token = auth_result_payload.get("access_token") - if not access_token: - logger.error( - "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", + logger.info( + "Acquiring agentic user token for agent_app_instance_id %s and agentic_user_id %s", agent_app_instance_id, agentic_user_id, - auth_result_payload, ) - return None + # MSAL in Python does not support async, so we use asyncio.to_thread to run it in + # a separate thread and avoid blocking the event loop + auth_result_payload = await _async_acquire_token_for_client( + instance_app, + scopes, + data={ + "user_id": agentic_user_id, + "user_federated_identity_credential": instance_token, + "grant_type": "user_fic", + }, + ) + + if not auth_result_payload: + logger.error( + "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", + agent_app_instance_id, + agentic_user_id, + auth_result_payload, + ) + return None + + access_token = auth_result_payload.get("access_token") + if not access_token: + logger.error( + "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", + agent_app_instance_id, + agentic_user_id, + auth_result_payload, + ) + return None - logger.info("Acquired agentic user token response.") - return access_token + logger.info("Acquired agentic user token response.") + return access_token diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/__init__.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/_metrics.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/_metrics.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py new file mode 100644 index 00000000..1437308b --- /dev/null +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py @@ -0,0 +1,11 @@ +# Spans + +SPAN_AUTH_GET_ACCESS_TOKEN = "agents.auth.getAccessToken" +SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF = "agents.auth.acquireTokenOnBehalfOf" +SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN = "agents.auth.getAgenticInstanceToken" +SPAN_AUTH_GET_AGENTIC_USER_TOKEN = "agents.auth.getAgenticUserToken" + +# Metrics + +METRIC_AUTH_TOKEN_REQUEST_DURATION = "agents.auth.token.request.duration" +METRIC_AUTH_TOKEN_REQUESTS = "agents.auth.token.requests" diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py new file mode 100644 index 00000000..c9106d38 --- /dev/null +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py @@ -0,0 +1,65 @@ +from contextlib import contextmanager +from collections.abc import Iterator + +from microsoft_agents.hosting.core.telemetry import ( + agents_telemetry, + core as common_constants, + _format_scopes, +) + +from . import constants, _metrics + + +@contextmanager +def start_span_auth_get_access_token( + scopes: list[str], auth_type: str +) -> Iterator[None]: + with agents_telemetry.start_as_current_span( + constants.SPAN_AUTH_GET_ACCESS_TOKEN + ) as span: + span.set_attributes( + { + common_constants.ATTR_AUTH_SCOPES: _format_scopes(scopes), + common_constants.ATTR_AUTH_TYPE: auth_type, + } + ) + yield + + +@contextmanager +def start_span_auth_acquire_token_on_behalf_of(scopes: list[str]) -> Iterator[None]: + with agents_telemetry.start_as_current_span( + constants.SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF + ) as span: + span.set_attribute(common_constants.ATTR_AUTH_SCOPES, _format_scopes(scopes)) + yield + + +@contextmanager +def start_span_auth_get_agentic_instance_token( + agentic_instance_id: str, +) -> Iterator[None]: + with agents_telemetry.start_as_current_span( + constants.SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN + ) as span: + span.set_attribute( + common_constants.ATTR_AGENTIC_INSTANCE_ID, agentic_instance_id + ) + yield + + +@contextmanager +def start_span_auth_get_agentic_user_token( + agentic_instance_id: str, agentic_user_id: str, scopes: list[str] +) -> Iterator[None]: + with agents_telemetry.start_as_current_span( + constants.SPAN_AUTH_GET_AGENTIC_USER_TOKEN + ): + span.set_attributes( + { + common_constants.ATTR_AGENTIC_INSTANCE_ID: agentic_instance_id, + common_constants.ATTR_AGENTIC_USER_ID: agentic_user_id, + common_constants.ATTR_AUTH_SCOPES: scopes, + } + ) + yield diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py index c384dd95..13b55ae3 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py @@ -11,6 +11,7 @@ HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase +from microsoft_agents.hosting.core.telemetry import spans from .agent_http_adapter import AgentHttpAdapter @@ -69,6 +70,7 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: Returns: aiohttp Response object. """ + # Adapt request to protocol adapted_request = AiohttpRequestAdapter(request) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index d0eb6c1e..9b18339c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -30,7 +30,8 @@ InvokeResponse, ) -from ..turn_context import TurnContext +from microsoft_agents.hosting.core.turn_context import TurnContext + from ..agent import Agent from ..authorization import Connections from .app_error import ApplicationError @@ -40,6 +41,7 @@ from ..channel_service_adapter import ChannelServiceAdapter from .oauth import Authorization from .typing_indicator import TypingIndicator +from .telemetry import spans from ._type_defs import RouteHandler, RouteSelector from ._routes import _RouteList, _Route, RouteRank, _agentic_selector @@ -669,50 +671,51 @@ async def on_turn(self, context: TurnContext): async def _on_turn(self, context: TurnContext): typing = None try: - if context.activity.type != ActivityTypes.typing: - if self._options.start_typing_timer: - typing = TypingIndicator(context) - typing.start() + with spans.AppOnTurn(context) as on_turn_span: + if context.activity.type != ActivityTypes.typing: + if self._options.start_typing_timer: + typing = TypingIndicator(context) + typing.start() - self._remove_mentions(context) + self._remove_mentions(context) - logger.debug("Initializing turn state") - turn_state = await self._initialize_state(context) - if ( - context.activity.type == ActivityTypes.message - or context.activity.type == ActivityTypes.invoke - ): + logger.debug("Initializing turn state") + turn_state = await self._initialize_state(context) + if ( + context.activity.type == ActivityTypes.message + or context.activity.type == ActivityTypes.invoke + ): - ( - auth_intercepts, - continuation_activity, - ) = await self._auth._on_turn_auth_intercept(context, turn_state) - if auth_intercepts: - if continuation_activity: - new_context = copy(context) - new_context.activity = continuation_activity - logger.info( - "Resending continuation activity %s", - continuation_activity.text, - ) - await self.on_turn(new_context) - await turn_state.save(context) - return + ( + auth_intercepts, + continuation_activity, + ) = await self._auth._on_turn_auth_intercept(context, turn_state) + if auth_intercepts: + if continuation_activity: + new_context = copy(context) + new_context.activity = continuation_activity + logger.info( + "Resending continuation activity %s", + continuation_activity.text, + ) + await self.on_turn(new_context) + await turn_state.save(context) + return - logger.debug("Running before turn middleware") - if not await self._run_before_turn_middleware(context, turn_state): - return + logger.debug("Running before turn middleware") + if not await self._run_before_turn_middleware(context, turn_state): + return - logger.debug("Running file downloads") - await self._handle_file_downloads(context, turn_state) + logger.debug("Running file downloads") + await self._handle_file_downloads(context, turn_state) - logger.debug("Running activity handlers") - await self._on_activity(context, turn_state) + logger.debug("Running activity handlers") + await self._on_activity(context, turn_state) - logger.debug("Running after turn middleware") - if await self._run_after_turn_middleware(context, turn_state): - await turn_state.save(context) - return + logger.debug("Running after turn middleware") + if await self._run_after_turn_middleware(context, turn_state): + await turn_state.save(context) + return except ApplicationError as err: logger.error( f"An application error occurred in the AgentApplication: {err}", @@ -777,23 +780,28 @@ async def _initialize_state(self, context: TurnContext) -> StateT: return turn_state async def _run_before_turn_middleware(self, context: TurnContext, state: StateT): - for before_turn in self._internal_before_turn: - is_ok = await before_turn(context, state) - if not is_ok: - await state.save(context) - return False - return True + with spans.AppBeforeTurn(context): + for before_turn in self._internal_before_turn: + is_ok = await before_turn(context, state) + if not is_ok: + await state.save(context) + return False + return True async def _handle_file_downloads(self, context: TurnContext, state: StateT): - if self._options.file_downloaders and len(self._options.file_downloaders) > 0: - input_files = state.temp.input_files if state.temp.input_files else [] - for file_downloader in self._options.file_downloaders: - logger.info( - f"Using file downloader: {file_downloader.__class__.__name__}" - ) - files = await file_downloader.download_files(context) - input_files.extend(files) - state.temp.input_files = input_files + with spans.AppDownloadFiles(context): + if ( + self._options.file_downloaders + and len(self._options.file_downloaders) > 0 + ): + input_files = state.temp.input_files if state.temp.input_files else [] + for file_downloader in self._options.file_downloaders: + logger.info( + f"Using file downloader: {file_downloader.__class__.__name__}" + ) + files = await file_downloader.download_files(context) + input_files.extend(files) + state.temp.input_files = input_files def _contains_non_text_attachments(self, context: TurnContext): non_text_attachments = filter( @@ -803,35 +811,37 @@ def _contains_non_text_attachments(self, context: TurnContext): return len(list(non_text_attachments)) > 0 async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): - for after_turn in self._internal_after_turn: - is_ok = await after_turn(context, state) - if not is_ok: - await state.save(context) - return False - return True + with spans.AppAfterTurn(context): + for after_turn in self._internal_after_turn: + is_ok = await after_turn(context, state) + if not is_ok: + await state.save(context) + return False + return True async def _on_activity(self, context: TurnContext, state: StateT): - for route in self._route_list: - if route.selector(context): - if not route.auth_handlers: - await route.handler(context, state) - else: - sign_in_complete = True - for auth_handler_id in route.auth_handlers: - if not ( - await self._auth._start_or_continue_sign_in( - context, state, auth_handler_id - ) - ).sign_in_complete(): - sign_in_complete = False - break - - if sign_in_complete: + with spans.AppRouteHandler(context): + for route in self._route_list: + if route.selector(context): + if not route.auth_handlers: await route.handler(context, state) - return - logger.warning( - f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" - ) + else: + sign_in_complete = True + for auth_handler_id in route.auth_handlers: + if not ( + await self._auth._start_or_continue_sign_in( + context, state, auth_handler_id + ) + ).sign_in_complete(): + sign_in_complete = False + break + + if sign_in_complete: + await route.handler(context, state) + return + logger.warning( + f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" + ) async def _start_long_running_call( self, context: TurnContext, func: Callable[[TurnContext], Awaitable] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py new file mode 100644 index 00000000..c0545330 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_ON_TURN = "agents.app.run" +SPAN_ROUTE_HANDLER = "agents.app.routeHandler" +SPAN_BEFORE_TURN = "agents.app.beforeTurn" +SPAN_AFTER_TURN = "agents.app.afterTurn" +SPAN_DOWNLOAD_FILES = "agents.app.downloadFiles" + +METRIC_TURN_TOTAL = "agents.turn.total" +METRIC_TURN_ERRORS = "agents.turn.errors" +METRIC_TURN_DURATION = "agents.turn.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py new file mode 100644 index 00000000..9562ba43 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +turn_total = agents_telemetry.meter.create_counter( + constants.METRIC_TURN_TOTAL, + "turn", + description="Total number of turns processed by the agent", +) + +turn_errors = agents_telemetry.meter.create_counter( + constants.METRIC_TURN_ERRORS, + "turn", + description="Number of turns that resulted in an error", +) + +turn_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_TURN_DURATION, + "ms", + description="Duration of agent turns in milliseconds", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py new file mode 100644 index 00000000..0cc1836e --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span +from microsoft_agents.activity import TurnContextProtocol +from microsoft_agents.hosting.core.telemetry import ( + AttributeMap, + attributes, + SimpleSpanWrapper, + get_conversation_id, +) +from . import constants, metrics + +class AppOnTurn(SimpleSpanWrapper): + """Span for the entire app run, starting from when an activity is received in the adapter, until a response is sent back (if applicable). This span is meant to be a parent span for all other spans created during the processing of the activity, and can be used to correlate all telemetry for a given app run.""" + + def __init__(self, turn_context: TurnContextProtocol): + """Initializes the AppOnTurn SpanWrapper. + + :param turn_context: The TurnContext for the app run, used to extract attributes for the span + """ + super().__init__(constants.SPAN_ON_TURN) + self._turn_context = turn_context + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the app run based on the outcome of the span.""" + if error is None: + metrics.turn_total.add(1) + metrics.turn_duration.record( + duration, + { + attributes.CONVERSATION_ID: ( + get_conversation_id(self._turn_context.activity) + ), + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, + }, + ) + else: + metrics.turn_errors.add(1) + + def _get_attributes(self) -> AttributeMap: + return { + attributes.CONVERSATION_ID: get_conversation_id(self._turn_context.activity), + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, + attributes.SERVICE_URL: self._turn_context.activity.service_url, + } + + def share(self, route_authorized: bool, route_matched: bool) -> None: + """Shares the span context for this app run with downstream spans, and adds attributes related to routing decisions + + :param route_authorized: Whether the route for this app run was authorized + :param route_matched: Whether the route for this app run was matched + """ + if self._span is not None: + self._span.set_attribute(attributes.ROUTE_AUTHORIZED, route_authorized) + self._span.set_attribute(attributes.ROUTE_MATCHED, route_matched) + +class AppRouteHandler(SimpleSpanWrapper): + """Span for handling the routing logic. From selection, through authorization, and through the invocation of the route handler.""" + + def __init__(self, turn_context: TurnContextProtocol): + """Initializes the AppRouteHandler SpanWrapper.""" + super().__init__(constants.SPAN_ROUTE_HANDLER) + self._turn_context = turn_context + + def _get_attributes(self) -> AttributeMap: + """Gets attributes for the AppRouteHandler span, based on the activity being processed.""" + return { + attributes.CONVERSATION_ID: get_conversation_id(self._turn_context.activity), + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, + attributes.SERVICE_URL: self._turn_context.activity.service_url, + } + +class AppBeforeTurn(SimpleSpanWrapper): + """Span for the logic that happens before the main turn processing. This is meant to capture telemetry for the pre-processing logic of the app run, and can be used to identify issues in the early stages of the app run before the main processing logic is invoked.""" + + def __init__(self): + """Initializes the AppBeforeTurn SpanWrapper.""" + super().__init__(constants.SPAN_BEFORE_TURN) + +class AppAfterTurn(SimpleSpanWrapper): + """Span for the logic that happens after the main turn processing. This is meant to capture telemetry for the post-processing logic of the app run, and can be used to identify issues in the later stages of the app run after the main processing logic is invoked.""" + + def __init__(self): + """Initializes the AppAfterTurn SpanWrapper.""" + super().__init__(constants.SPAN_AFTER_TURN) + +class AppDownloadFiles(SimpleSpanWrapper): + """Span for the logic related to downloading files in the app. This can be used to capture telemetry for file download operations, and to identify issues related to file downloads in the app.""" + + def __init__(self, turn_context: TurnContextProtocol): + """Initializes the AppDownloadFiles SpanWrapper.""" + super().__init__(constants.SPAN_DOWNLOAD_FILES) + self._turn_context = turn_context + + def _get_attributes(self) -> AttributeMap: + return { + attributes.ATTACHMENT_COUNT: len(self._turn_context.activity.attachments or []), + } \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 20539ee6..bad61ec5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -35,6 +35,7 @@ AuthenticationConstants, ClaimsIdentity, ) +from microsoft_agents.hosting.core.telemetry.adapter import spans from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .channel_adapter import ChannelAdapter from .turn_context import TurnContext @@ -67,56 +68,64 @@ async def send_activities( :rtype: list[:class:`microsoft_agents.activity.ResourceResponse`] :raises TypeError: If context or activities are None/invalid. """ - if not context: - raise TypeError("Expected TurnContext but got None instead") - - if activities is None: - raise TypeError("Expected Activities list but got None instead") - - if len(activities) == 0: - raise TypeError("Expecting one or more activities, but the list was empty.") - - responses = [] - - for activity in activities: - activity.id = None - - response = ResourceResponse() - - if activity.type == ActivityTypes.invoke_response: - context.turn_state[self.INVOKE_RESPONSE_KEY] = activity - elif ( - activity.type == ActivityTypes.trace - and activity.channel_id != Channels.emulator - ): - # no-op - pass - else: - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), + with spans.AdapterSendActivities(activities): + + if not context: + raise TypeError("Expected TurnContext but got None instead") + + if activities is None: + raise TypeError("Expected Activities list but got None instead") + + if len(activities) == 0: + raise TypeError( + "Expecting one or more activities, but the list was empty." ) - if not connector_client: - raise Error("Unable to extract ConnectorClient from turn context.") - - if activity.reply_to_id: - response = await connector_client.conversations.reply_to_activity( - activity.conversation.id, - activity.reply_to_id, - activity, - ) + + responses = [] + + for activity in activities: + activity.id = None + + response = ResourceResponse() + + if activity.type == ActivityTypes.invoke_response: + context.turn_state[self.INVOKE_RESPONSE_KEY] = activity + elif ( + activity.type == ActivityTypes.trace + and activity.channel_id != Channels.emulator + ): + # no-op + pass else: - response = ( - await connector_client.conversations.send_to_conversation( - activity.conversation.id, - activity, - ) + connector_client = cast( + ConnectorClientBase, + context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), ) - response = response or ResourceResponse(id=activity.id or "") + if not connector_client: + raise Error( + "Unable to extract ConnectorClient from turn context." + ) - responses.append(response) + if activity.reply_to_id: + response = ( + await connector_client.conversations.reply_to_activity( + activity.conversation.id, + activity.reply_to_id, + activity, + ) + ) + else: + response = ( + await connector_client.conversations.send_to_conversation( + activity.conversation.id, + activity, + ) + ) + response = response or ResourceResponse(id=activity.id or "") - return responses + responses.append(response) + + return responses async def update_activity(self, context: TurnContext, activity: Activity): """ @@ -130,22 +139,24 @@ async def update_activity(self, context: TurnContext, activity: Activity): :rtype: :class:`microsoft_agents.activity.ResourceResponse` :raises TypeError: If context or activity are None/invalid. """ - if not context: - raise TypeError("Expected TurnContext but got None instead") + with spans.AdapterUpdateActivity(activity): - if activity is None: - raise TypeError("Expected Activity but got None instead") + if not context: + raise TypeError("Expected TurnContext but got None instead") - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), - ) - if not connector_client: - raise Error("Unable to extract ConnectorClient from turn context.") + if activity is None: + raise TypeError("Expected Activity but got None instead") - return await connector_client.conversations.update_activity( - activity.conversation.id, activity.id, activity - ) + connector_client = cast( + ConnectorClientBase, + context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), + ) + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + return await connector_client.conversations.update_activity( + activity.conversation.id, activity.id, activity + ) async def delete_activity( self, context: TurnContext, reference: ConversationReference @@ -159,22 +170,24 @@ async def delete_activity( :type reference: :class:`microsoft_agents.activity.ConversationReference` :raises TypeError: If context or reference are None/invalid. """ - if not context: - raise TypeError("Expected TurnContext but got None instead") + with spans.AdapterDeleteActivity(context.activity): - if not reference: - raise TypeError("Expected ConversationReference but got None instead") + if not context: + raise TypeError("Expected TurnContext but got None instead") - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), - ) - if not connector_client: - raise Error("Unable to extract ConnectorClient from turn context.") + if not reference: + raise TypeError("Expected ConversationReference but got None instead") - await connector_client.conversations.delete_activity( - reference.conversation.id, reference.activity_id - ) + connector_client = cast( + ConnectorClientBase, + context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), + ) + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + await connector_client.conversations.delete_activity( + reference.conversation.id, reference.activity_id + ) async def continue_conversation( # pylint: disable=arguments-differ self, @@ -196,21 +209,22 @@ async def continue_conversation( # pylint: disable=arguments-differ :param callback: The method to call for the resulting agent turn. :type callback: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`], Awaitable] """ - if not callable: - raise TypeError( - "Expected Callback (Callable[[TurnContext], Awaitable]) but got None instead" - ) + with spans.AdapterContinueConversation(continuation_activity): + if not callable: + raise TypeError( + "Expected Callback (Callable[[TurnContext], Awaitable]) but got None instead" + ) - self._validate_continuation_activity(continuation_activity) + self._validate_continuation_activity(continuation_activity) - claims_identity = self.create_claims_identity(agent_app_id) + claims_identity = self.create_claims_identity(agent_app_id) - return await self.process_proactive( - claims_identity, - continuation_activity, - claims_identity.get_token_audience(), - callback, - ) + return await self.process_proactive( + claims_identity, + continuation_activity, + claims_identity.get_token_audience(), + callback, + ) async def continue_conversation_with_claims( self, @@ -231,12 +245,13 @@ async def continue_conversation_with_claims( :param audience: The audience for the conversation. :type audience: Optional[str] """ - return await self.process_proactive( - claims_identity, - continuation_activity, - audience or claims_identity.get_token_audience(), - callback, - ) + with spans.AdapterContinueConversation(continuation_activity): + return await self.process_proactive( + claims_identity, + continuation_activity, + audience or claims_identity.get_token_audience(), + callback, + ) async def create_conversation( # pylint: disable=arguments-differ self, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 73b642a2..8ea5c206 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -18,9 +18,11 @@ PagedMembersResult, ) from microsoft_agents.hosting.core.connector import ConnectorClientBase +from microsoft_agents.hosting.core.telemetry import spans from ..attachments_base import AttachmentsBase from ..conversations_base import ConversationsBase from ..get_product_info import get_product_info +from ..telemetry import spans logger = logging.getLogger(__name__) @@ -94,33 +96,34 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: :param view_id: The ID of the view. :return: The attachment as a readable stream. """ - if attachment_id is None: - logger.error( - "AttachmentsOperations.get_attachment(): attachmentId is required", - stack_info=True, - ) - raise ValueError("attachmentId is required") - if view_id is None: - logger.error( - "AttachmentsOperations.get_attachment(): viewId is required", - stack_info=True, - ) - raise ValueError("viewId is required") - - url = f"v3/attachments/{attachment_id}/views/{view_id}" - - logger.info( - "Getting attachment for ID: %s, View ID: %s", attachment_id, view_id - ) - async with self.client.get(url) as response: - if response.status >= 300: + with spans.ConnectorGetAttachment(attachment_id): + if attachment_id is None: logger.error( - "Error getting attachment: %s", response.status, stack_info=True + "AttachmentsOperations.get_attachment(): attachmentId is required", + stack_info=True, ) - response.raise_for_status() + raise ValueError("attachmentId is required") + if view_id is None: + logger.error( + "AttachmentsOperations.get_attachment(): viewId is required", + stack_info=True, + ) + raise ValueError("viewId is required") + + url = f"v3/attachments/{attachment_id}/views/{view_id}" + + logger.info( + "Getting attachment for ID: %s, View ID: %s", attachment_id, view_id + ) + async with self.client.get(url) as response: + if response.status >= 300: + logger.error( + "Error getting attachment: %s", response.status, stack_info=True + ) + response.raise_for_status() - data = await response.read() - return BytesIO(data) + data = await response.read() + return BytesIO(data) class ConversationsOperations(ConversationsBase): @@ -193,44 +196,47 @@ async def reply_to_activity( :param body: The activity object. :return: The resource response. """ - if not conversation_id or not activity_id: - logger.error( - "ConversationsOperations.reply_to_activity(): conversationId and activityId are required", - stack_info=True, - ) - raise ValueError("conversationId and activityId are required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities/{activity_id}" - - logger.info( - "Replying to activity: %s in conversation: %s. Activity type is %s", - activity_id, - conversation_id, - body.type, - ) - - async with self.client.post( - url, - json=body.model_dump( - by_alias=True, exclude_unset=True, exclude_none=True, mode="json" - ), - ) as response: - result = await response.json() if response.content_length else {} - - if response.status >= 300: + with spans.ConnectorReplyToActivity(conversation_id, activity_id): + if not conversation_id or not activity_id: logger.error( - "Error replying to activity: %s", - result or response.status, + "ConversationsOperations.reply_to_activity(): conversationId and activityId are required", stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId and activityId are required") + + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities/{activity_id}" logger.info( - "Reply to conversation/activity: %s, %s", result.get("id"), activity_id + "Replying to activity: %s in conversation: %s. Activity type is %s", + activity_id, + conversation_id, + body.type, ) - return ResourceResponse.model_validate(result) + async with self.client.post( + url, + json=body.model_dump( + by_alias=True, exclude_unset=True, exclude_none=True, mode="json" + ), + ) as response: + result = await response.json() if response.content_length else {} + + if response.status >= 300: + logger.error( + "Error replying to activity: %s", + result or response.status, + stack_info=True, + ) + response.raise_for_status() + + logger.info( + "Reply to conversation/activity: %s, %s", + result.get("id"), + activity_id, + ) + + return ResourceResponse.model_validate(result) async def send_to_conversation( self, conversation_id: str, body: Activity @@ -242,35 +248,36 @@ async def send_to_conversation( :param body: The activity object. :return: The resource response. """ - if not conversation_id: - logger.error( - "ConversationsOperations.sent_to_conversation(): conversationId is required", - stack_info=True, - ) - raise ValueError("conversationId is required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities" - - logger.info( - "Sending to conversation: %s. Activity type is %s", - conversation_id, - body.type, - ) - async with self.client.post( - url, - json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), - ) as response: - if response.status >= 300: + with spans.ConnectorSendToConversation(conversation_id, body.id): + if not conversation_id: logger.error( - "Error sending to conversation: %s", - response.status, + "ConversationsOperations.sent_to_conversation(): conversationId is required", stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId is required") - data = await response.json() - return ResourceResponse.model_validate(data) + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities" + + logger.info( + "Sending to conversation: %s. Activity type is %s", + conversation_id, + body.type, + ) + async with self.client.post( + url, + json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), + ) as response: + if response.status >= 300: + logger.error( + "Error sending to conversation: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return ResourceResponse.model_validate(data) async def update_activity( self, conversation_id: str, activity_id: str, body: Activity @@ -283,34 +290,35 @@ async def update_activity( :param body: The activity object. :return: The resource response. """ - if not conversation_id or not activity_id: - logger.error( - "ConversationsOperations.update_activity(): conversationId and activityId are required", - stack_info=True, - ) - raise ValueError("conversationId and activityId are required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities/{activity_id}" - - logger.info( - "Updating activity: %s in conversation: %s. Activity type is %s", - activity_id, - conversation_id, - body.type, - ) - async with self.client.put( - url, - json=body.model_dump(by_alias=True, exclude_unset=True), - ) as response: - if response.status >= 300: + with spans.ConnectorUpdateActivity(conversation_id, activity_id): + if not conversation_id or not activity_id: logger.error( - "Error updating activity: %s", response.status, stack_info=True + "ConversationsOperations.update_activity(): conversationId and activityId are required", + stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId and activityId are required") - data = await response.json() - return ResourceResponse.model_validate(data) + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities/{activity_id}" + + logger.info( + "Updating activity: %s in conversation: %s. Activity type is %s", + activity_id, + conversation_id, + body.type, + ) + async with self.client.put( + url, + json=body.model_dump(by_alias=True, exclude_unset=True), + ) as response: + if response.status >= 300: + logger.error( + "Error updating activity: %s", response.status, stack_info=True + ) + response.raise_for_status() + + data = await response.json() + return ResourceResponse.model_validate(data) async def delete_activity(self, conversation_id: str, activity_id: str) -> None: """ @@ -319,27 +327,28 @@ async def delete_activity(self, conversation_id: str, activity_id: str) -> None: :param conversation_id: The ID of the conversation. :param activity_id: The ID of the activity. """ - if not conversation_id or not activity_id: - logger.error( - "ConversationsOperations.delete_activity(): conversationId and activityId are required", - stack_info=True, - ) - raise ValueError("conversationId and activityId are required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities/{activity_id}" - - logger.info( - "Deleting activity: %s from conversation: %s", - activity_id, - conversation_id, - ) - async with self.client.delete(url) as response: - if response.status >= 300: + with spans.ConnectorDeleteActivity(conversation_id, activity_id): + if not conversation_id or not activity_id: logger.error( - "Error deleting activity: %s", response.status, stack_info=True + "ConversationsOperations.delete_activity(): conversationId and activityId are required", + stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId and activityId are required") + + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities/{activity_id}" + + logger.info( + "Deleting activity: %s from conversation: %s", + activity_id, + conversation_id, + ) + async with self.client.delete(url) as response: + if response.status >= 300: + logger.error( + "Error deleting activity: %s", response.status, stack_info=True + ) + response.raise_for_status() async def upload_attachment( self, conversation_id: str, body: AttachmentData @@ -351,38 +360,41 @@ async def upload_attachment( :param body: The attachment data. :return: The resource response. """ - if conversation_id is None: - logger.error( - "ConversationsOperations.upload_attachment(): conversationId is required", - stack_info=True, - ) - raise ValueError("conversationId is required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/attachments" - - # Convert the AttachmentData to a dictionary - attachment_dict = { - "name": body.name, - "originalBase64": body.original_base64, - "type": body.type, - "thumbnailBase64": body.thumbnail_base64, - } - - logger.info( - "Uploading attachment to conversation: %s, Attachment name: %s", - conversation_id, - body.name, - ) - async with self.client.post(url, json=attachment_dict) as response: - if response.status >= 300: + with spans.ConnectorUploadAttachment(conversation_id): + if conversation_id is None: logger.error( - "Error uploading attachment: %s", response.status, stack_info=True + "ConversationsOperations.upload_attachment(): conversationId is required", + stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId is required") - data = await response.json() - return ResourceResponse.model_validate(data) + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/attachments" + + # Convert the AttachmentData to a dictionary + attachment_dict = { + "name": body.name, + "originalBase64": body.original_base64, + "type": body.type, + "thumbnailBase64": body.thumbnail_base64, + } + + logger.info( + "Uploading attachment to conversation: %s, Attachment name: %s", + conversation_id, + body.name, + ) + async with self.client.post(url, json=attachment_dict) as response: + if response.status >= 300: + logger.error( + "Error uploading attachment: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return ResourceResponse.model_validate(data) async def get_conversation_members( self, conversation_id: str @@ -393,30 +405,31 @@ async def get_conversation_members( :param conversation_id: The ID of the conversation. :return: A list of members. """ - if not conversation_id: - logger.error( - "ConversationsOperations.get_conversation_members(): conversationId is required", - stack_info=True, - ) - raise ValueError("conversationId is required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/members" - - logger.info( - "Getting conversation members for conversation: %s", conversation_id - ) - async with self.client.get(url) as response: - if response.status >= 300: + with spans.ConnectorGetConversationMembers(): + if not conversation_id: logger.error( - "Error getting conversation members: %s", - response.status, + "ConversationsOperations.get_conversation_members(): conversationId is required", stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId is required") - data = await response.json() - return [ChannelAccount.model_validate(member) for member in data] + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/members" + + logger.info( + "Getting conversation members for conversation: %s", conversation_id + ) + async with self.client.get(url) as response: + if response.status >= 300: + logger.error( + "Error getting conversation members: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return [ChannelAccount.model_validate(member) for member in data] async def get_conversation_member( self, conversation_id: str, member_id: str @@ -428,32 +441,33 @@ async def get_conversation_member( :param member_id: The ID of the member. :return: The member. """ - if not conversation_id or not member_id: - logger.error( - "ConversationsOperations.get_conversation_member(): conversationId and memberId are required", - stack_info=True, - ) - raise ValueError("conversationId and memberId are required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/members/{member_id}" - - logger.info( - "Getting conversation member: %s from conversation: %s", - member_id, - conversation_id, - ) - async with self.client.get(url) as response: - if response.status >= 300: + with spans.ConnectorGetConversationMembers(): + if not conversation_id or not member_id: logger.error( - "Error getting conversation member: %s", - response.status, + "ConversationsOperations.get_conversation_member(): conversationId and memberId are required", stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId and memberId are required") - data = await response.json() - return ChannelAccount.model_validate(data) + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/members/{member_id}" + + logger.info( + "Getting conversation member: %s from conversation: %s", + member_id, + conversation_id, + ) + async with self.client.get(url) as response: + if response.status >= 300: + logger.error( + "Error getting conversation member: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return ChannelAccount.model_validate(data) async def delete_conversation_member( self, conversation_id: str, member_id: str diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py new file mode 100644 index 00000000..69590fdb --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_REPLY_TO_ACTIVITY = "agents.connector.replyToActivity" +SPAN_SEND_TO_CONVERSATION = "agents.connector.sendToConversation" +SPAN_UPDATE_ACTIVITY = "agents.connector.updateActivity" +SPAN_DELETE_ACTIVITY = "agents.connector.deleteActivity" +SPAN_CREATE_CONVERSATION = "agents.connector.createConversation" +SPAN_GET_CONVERSATIONS = "agents.connector.getConversations" +SPAN_GET_CONVERSATION_MEMBERS = "agents.connector.getConversationMembers" +SPAN_UPLOAD_ATTACHMENT = "agents.connector.uploadAttachment" +SPAN_GET_ATTACHMENT = "agents.connector.getAttachment" + +METRIC_REQUESTS_TOTAL = "agents.connector.requests" +METRIC_REQUEST_DURATION = "agents.connector.request.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py new file mode 100644 index 00000000..56230642 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +connector_request_total = agents_telemetry.meter.create_counter( + constants.METRIC_REQUESTS_TOTAL, + "request", + description="Total number of connector requests made by the agent", +) + +connector_request_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_REQUEST_DURATION, + "ms", + description="Duration of connector requests in milliseconds", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/spans.py new file mode 100644 index 00000000..84123411 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/spans.py @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from microsoft_agents.hosting.core.telemetry import ( + attributes, + SimpleSpanWrapper, + AttributeMap, +) +from . import metrics, constants + +class _ConnectorSpanWrapper(SimpleSpanWrapper): + """Base SpanWrapper for spans related to connector operations in the adapter. This is meant to be a base class for spans related to connector operations, such as creating a connector client or creating a user token, and can be used to share common functionality and attributes related to connector operations.""" + + def __init__(self, span_name: str, *, conversation_id: str | None = None, activity_id: str | None = None): + """Initializes the _ConnectorSpanWrapper span.""" + super().__init__(span_name) + self._conversation_id = conversation_id + self._activity_id = activity_id + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the connector operation based on the outcome of the span.""" + metrics.connector_request_duration.record(duration) + metrics.connector_request_total.add(1) + + def _get_attributes(self) -> dict[str, str]: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the connector operation being performed. + + NOTE: a dict is the annotated return type to allow child classes to add additional attributes. + """ + attr_dict = {} + if self._conversation_id is not None: + attr_dict[attributes.CONVERSATION_ID] = self._conversation_id + if self._activity_id is not None: + attr_dict[attributes.ACTIVITY_ID] = self._activity_id + return attr_dict + +class ConnectorReplyToActivity(_ConnectorSpanWrapper): + """Span for replying to an activity using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str | None): + """Initializes the ConnectorReplyToActivity span.""" + super().__init__(constants.SPAN_REPLY_TO_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id) + +class ConnectorSendToConversation(_ConnectorSpanWrapper): + """Span for sending to a conversation using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str | None): + """Initializes the ConnectorSendToConversation span.""" + super().__init__(constants.SPAN_SEND_TO_CONVERSATION, + conversation_id=conversation_id, + activity_id=activity_id) + +class ConnectorUpdateActivity(_ConnectorSpanWrapper): + """Span for updating an activity using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str | None): + """Initializes the ConnectorUpdateActivity span.""" + super().__init__(constants.SPAN_UPDATE_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id) + +class ConnectorDeleteActivity(_ConnectorSpanWrapper): + """Span for deleting an activity using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str | None): + """Initializes the ConnectorDeleteActivity span.""" + super().__init__(constants.SPAN_DELETE_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id) + +class ConnectorCreateConversation(_ConnectorSpanWrapper): + """Span for creating a conversation using the connector client in the adapter.""" + + def __init__(self): + """Initializes the ConnectorCreateConversation span.""" + super().__init__(constants.SPAN_CREATE_CONVERSATION) + +class ConnectorGetConversations(_ConnectorSpanWrapper): + """Span for getting conversations using the connector client in the adapter.""" + + def __init__(self): + """Initializes the ConnectorGetConversations span.""" + super().__init__(constants.SPAN_GET_CONVERSATIONS) + +class ConnectorGetConversationMembers(_ConnectorSpanWrapper): + """Span for getting conversation members using the connector client in the adapter.""" + + def __init__(self): + """Initializes the ConnectorGetConversationMembers span.""" + super().__init__(constants.SPAN_GET_CONVERSATION_MEMBERS) + +class ConnectorUploadAttachment(_ConnectorSpanWrapper): + """Span for uploading an attachment using the connector client in the adapter.""" + + def __init__(self, conversation_id: str): + """Initializes the ConnectorUploadAttachment span.""" + super().__init__(constants.SPAN_UPLOAD_ATTACHMENT, + conversation_id=conversation_id) + +class ConnectorGetAttachment(_ConnectorSpanWrapper): + """Span for getting an attachment using the connector client in the adapter.""" + + def __init__(self, attachment_id: str): + """Initializes the ConnectorGetAttachment span.""" + super().__init__(constants.SPAN_GET_ATTACHMENT) + self._attachment_id = attachment_id + + def _get_attributes(self) -> AttributeMap: + attr_dict = super()._get_attributes() + attr_dict[attributes.ATTACHMENT_ID] = self._attachment_id + return attr_dict diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py index e86142a2..4ad4e1f1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py @@ -16,6 +16,7 @@ RestChannelServiceClientFactory, TurnContext, ) +from microsoft_agents.hosting.core.telemetry import spans from ._http_request_protocol import HttpRequestProtocol from ._http_response import HttpResponse, HttpResponseFactory @@ -96,38 +97,40 @@ async def process_request( activity: Activity = Activity.model_validate(body) - # Get claims identity (default to anonymous if not set by middleware) - claims_identity: ClaimsIdentity = ( - request.get_claims_identity() or ClaimsIdentity({}, False) - ) - - # Validate required activity fields - if ( - not activity.type - or not activity.conversation - or not activity.conversation.id - ): - return HttpResponseFactory.bad_request( - "Activity must have type and conversation.id" - ) + with spans.start_span_adapter_process(activity): - try: - # Process the inbound activity with the agent - invoke_response = await self.process_activity( - claims_identity, activity, agent.on_turn + # Get claims identity (default to anonymous if not set by middleware) + claims_identity: ClaimsIdentity = ( + request.get_claims_identity() or ClaimsIdentity({}, False) ) - # Check if we need to return a synchronous response + # Validate required activity fields if ( - activity.type == "invoke" - or activity.delivery_mode == DeliveryModes.expect_replies + not activity.type + or not activity.conversation + or not activity.conversation.id ): - # Invoke and ExpectReplies cannot be performed async - return HttpResponseFactory.json( - invoke_response.body, invoke_response.status + return HttpResponseFactory.bad_request( + "Activity must have type and conversation.id" + ) + + try: + # Process the inbound activity with the agent + invoke_response = await self.process_activity( + claims_identity, activity, agent.on_turn ) - return HttpResponseFactory.accepted() + # Check if we need to return a synchronous response + if ( + activity.type == "invoke" + or activity.delivery_mode == DeliveryModes.expect_replies + ): + # Invoke and ExpectReplies cannot be performed async + return HttpResponseFactory.json( + invoke_response.body, invoke_response.status + ) + + return HttpResponseFactory.accepted() - except PermissionError: - return HttpResponseFactory.unauthorized() + except PermissionError: + return HttpResponseFactory.unauthorized() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index 4855b3ea..4abb11b5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -16,6 +16,7 @@ from microsoft_agents.hosting.core.connector.client import UserTokenClient from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient from microsoft_agents.hosting.core.connector.mcs import MCSConnectorClient +from microsoft_agents.hosting.core.telemetry.adapter import spans from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .turn_context import TurnContext @@ -105,36 +106,44 @@ async def create_connector_client( "RestChannelServiceClientFactory.create_connector_client: audience can't be None or Empty" ) - if context and context.activity.is_agentic_request(): - token = await self._get_agentic_token(context, service_url) - else: - token_provider: AccessTokenProviderBase = ( - self._connection_manager.get_token_provider( - claims_identity, service_url + is_agentic_request = context.activity.is_agentic_request() if context else False + + with spans.AdapterCreateConnectorClient( + service_url=service_url, + scopes=scopes, + is_agentic_request=is_agentic_request, + ): + + if context and is_agentic_request: + token = await self._get_agentic_token(context, service_url) + else: + token_provider: AccessTokenProviderBase = ( + self._connection_manager.get_token_provider( + claims_identity, service_url + ) + if not use_anonymous + else self._ANONYMOUS_TOKEN_PROVIDER ) - if not use_anonymous - else self._ANONYMOUS_TOKEN_PROVIDER - ) - token = await token_provider.get_access_token( - audience, scopes or claims_identity.get_token_scope() - ) + token = await token_provider.get_access_token( + audience, scopes or claims_identity.get_token_scope() + ) - # Check if this is a connector request (e.g., from Copilot Studio) - if ( - context - and context.activity.recipient - and context.activity.recipient.role == RoleTypes.connector_user - ) or service_url.startswith("https://pvaruntime"): - return MCSConnectorClient( + # Check if this is a connector request (e.g., from Copilot Studio) + if ( + context + and context.activity.recipient + and context.activity.recipient.role == RoleTypes.connector_user + ) or service_url.startswith("https://pvaruntime"): + return MCSConnectorClient( + endpoint=service_url, + ) + + return TeamsConnectorClient( endpoint=service_url, + token=token, ) - return TeamsConnectorClient( - endpoint=service_url, - token=token, - ) - async def create_user_token_client( self, context: TurnContext, @@ -149,28 +158,33 @@ async def create_user_token_client( """ if not context or not claims_identity: raise ValueError("context and claims_identity are required") + + scopes = claims_identity.get_token_scope() if claims_identity else None + + with spans.AdapterCreateUserTokenClient( + token_service_endpoint=self._token_service_endpoint, + scopes=scopes, + ): + + if use_anonymous: + return UserTokenClient(endpoint=self._token_service_endpoint, token="") + + if context.activity.is_agentic_request(): + token = await self._get_agentic_token(context, self._token_service_endpoint) + else: + token_provider = self._connection_manager.get_token_provider( + claims_identity, self._token_service_endpoint + ) - if use_anonymous: - return UserTokenClient(endpoint=self._token_service_endpoint, token="") - - if context.activity.is_agentic_request(): - token = await self._get_agentic_token(context, self._token_service_endpoint) - else: - scopes = claims_identity.get_token_scope() + token = await token_provider.get_access_token( + self._token_service_audience, scopes + ) - token_provider = self._connection_manager.get_token_provider( - claims_identity, self._token_service_endpoint - ) + if not token: + logger.error("Failed to obtain token for user token client") + raise ValueError("Failed to obtain token for user token client") - token = await token_provider.get_access_token( - self._token_service_audience, scopes + return UserTokenClient( + endpoint=self._token_service_endpoint, + token=token, ) - - if not token: - logger.error("Failed to obtain token for user token client") - raise ValueError("Failed to obtain token for user token client") - - return UserTokenClient( - endpoint=self._token_service_endpoint, - token=token, - ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py index 31560b27..39d6f0e1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py @@ -4,9 +4,12 @@ from threading import Lock from typing import TypeVar +from microsoft_agents.hosting.core.telemetry import spans + from ._type_aliases import JSON from .storage import Storage from .store_item import StoreItem +from .telemetry import spans StoreItemT = TypeVar("StoreItemT", bound=StoreItem) @@ -24,43 +27,46 @@ async def read( raise ValueError("Storage.read(): Keys are required when reading.") if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - - result: dict[str, StoreItem] = {} - with self._lock: - for key in keys: - if key == "": - raise ValueError("MemoryStorage.read(): key cannot be empty") - if key in self._memory: - if not target_cls: - result[key] = self._memory[key] - else: - try: - result[key] = target_cls.from_json_to_store_item( - self._memory[key] - ) - except AttributeError as error: - raise TypeError( - f"MemoryStorage.read(): could not deserialize in-memory item into {target_cls} class. Error: {error}" - ) - return result + + with spans.StorageRead(len(keys)): + result: dict[str, StoreItem] = {} + with self._lock: + for key in keys: + if key == "": + raise ValueError("MemoryStorage.read(): key cannot be empty") + if key in self._memory: + if not target_cls: + result[key] = self._memory[key] + else: + try: + result[key] = target_cls.from_json_to_store_item( + self._memory[key] + ) + except AttributeError as error: + raise TypeError( + f"MemoryStorage.read(): could not deserialize in-memory item into {target_cls} class. Error: {error}" + ) + return result async def write(self, changes: dict[str, StoreItem]): if not changes: raise ValueError("MemoryStorage.write(): changes cannot be None") - with self._lock: - for key in changes: - if key == "": - raise ValueError("MemoryStorage.write(): key cannot be empty") - self._memory[key] = changes[key].store_item_to_json() + with spans.StorageWrite(len(changes)): + with self._lock: + for key in changes: + if key == "": + raise ValueError("MemoryStorage.write(): key cannot be empty") + self._memory[key] = changes[key].store_item_to_json() async def delete(self, keys: list[str]): if not keys: raise ValueError("Storage.delete(): Keys are required when deleting.") - with self._lock: - for key in keys: - if key == "": - raise ValueError("MemoryStorage.delete(): key cannot be empty") - if key in self._memory: - del self._memory[key] + with spans.StorageDelete(len(keys)): + with self._lock: + for key in keys: + if key == "": + raise ValueError("MemoryStorage.delete(): key cannot be empty") + if key in self._memory: + del self._memory[key] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py index 9c66ac31..1e9ddd86 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py @@ -2,11 +2,11 @@ # Licensed under the MIT License. from typing import Protocol, TypeVar, Type, Union -from abc import ABC, abstractmethod +from abc import abstractmethod from asyncio import gather -from ._type_aliases import JSON from .store_item import StoreItem +from .telemetry import spans StoreItemT = TypeVar("StoreItemT", bound=StoreItem) @@ -69,12 +69,18 @@ async def read( if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - await self.initialize() + with spans.StorageRead(len(keys)): + await self.initialize() - items: list[tuple[Union[str, None], Union[StoreItemT, None]]] = await gather( - *[self._read_item(key, target_cls=target_cls, **kwargs) for key in keys] - ) - return {key: value for key, value in items if key is not None} + items: list[tuple[Union[str, None], Union[StoreItemT, None]]] = ( + await gather( + *[ + self._read_item(key, target_cls=target_cls, **kwargs) + for key in keys + ] + ) + ) + return {key: value for key, value in items if key is not None} @abstractmethod async def _write_item(self, key: str, value: StoreItemT) -> None: @@ -85,9 +91,12 @@ async def write(self, changes: dict[str, StoreItemT]) -> None: if not changes: raise ValueError("Storage.write(): Changes are required when writing.") - await self.initialize() + with spans.StorageWrite(len(changes)): + await self.initialize() - await gather(*[self._write_item(key, value) for key, value in changes.items()]) + await gather( + *[self._write_item(key, value) for key, value in changes.items()] + ) @abstractmethod async def _delete_item(self, key: str) -> None: @@ -98,6 +107,7 @@ async def delete(self, keys: list[str]) -> None: if not keys: raise ValueError("Storage.delete(): Keys are required when deleting.") - await self.initialize() + with spans.StorageDelete(len(keys)): + await self.initialize() - await gather(*[self._delete_item(key) for key in keys]) + await gather(*[self._delete_item(key) for key in keys]) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py new file mode 100644 index 00000000..c80cee50 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_STORAGE_READ = "agents.storage.read" +SPAN_STORAGE_WRITE = "agents.storage.write" +SPAN_STORAGE_DELETE = "agents.storage.delete" + +METRIC_STORAGE_OPERATION_TOTAL = "agents.storage.operation.total" +METRIC_STORAGE_OPERATION_DURATION = "agents.storage.operation.duration" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py new file mode 100644 index 00000000..47aa8322 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +storage_operation_total = agents_telemetry.meter.create_counter( + constants.METRIC_STORAGE_OPERATION_TOTAL, + "operation", + description="Number of storage operations performed by the agent", +) +storage_operation_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_STORAGE_OPERATION_DURATION, + "ms", + description="Duration of storage operations in milliseconds", +) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py new file mode 100644 index 00000000..36462e9e --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from microsoft_agents.hosting.core.telemetry import ( + resource as common_constants, + SimpleSpanWrapper, +) +from . import metrics, constants + +class _StorageSpanWrapper(SimpleSpanWrapper): + """Base SpanWrapper for spans related to storage operations. This is meant to be a base class for spans related to storage operations, such as retrieving or saving state, and can be used to share common functionality and attributes related to storage operations.""" + + def __init__(self, span_name: str, *, key_count: int): + """Initializes the _StorageSpanWrapper span.""" + super().__init__(span_name) + self._key_count = key_count + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the storage operation based on the outcome of the span.""" + metrics.storage_operation_duration.record(duration) + metrics.storage_operation_total.add(1) + + def _get_attributes(self) -> dict[str, str | int]: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the storage operation being performed. + + NOTE: a dict is the annotated return type to allow child classes to add additional attributes. + """ + return { + common_constants.ATTR_KEY_COUNT: self._key_count, + } + +class StorageRead(_StorageSpanWrapper): + """Span for reading from storage.""" + + def __init__(self, key_count: int): + """Initializes the StorageRead span.""" + super().__init__(constants.SPAN_STORAGE_READ, key_count=key_count) + +class StorageWrite(_StorageSpanWrapper): + """Span for writing to storage.""" + + def __init__(self, key_count: int): + """Initializes the StorageWrite span.""" + super().__init__(constants.SPAN_STORAGE_WRITE, key_count=key_count) + +class StorageDelete(_StorageSpanWrapper): + """Span for deleting from storage.""" + + def __init__(self, key_count: int): + """Initializes the StorageDelete span.""" + super().__init__(constants.SPAN_STORAGE_DELETE, key_count=key_count) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py new file mode 100644 index 00000000..7d068aba --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -0,0 +1,42 @@ +## DESIGN +# This design is similar to how error codes are implemented and maintained. +# The alternative was to inject all of this telemetry logic inline with the code it instruments. +# While some spans are simple, others require more involved mapping of attributes or +# even emitting metrics. +# +# This design hides the "mess" of telemetry to one location rather than throughout the codebase. +# +# NOTE: this module should not be auto-loaded from __init__.py in order to avoid + +from . import attributes +from .core._agents_telemetry import ( + agents_telemetry, +) +from .core import ( + SERVICE_NAME, + SERVICE_VERSION, + RESOURCE, + AttributeMap, + BaseSpanWrapper, + SimpleSpanWrapper, +) + +from .utils import ( + format_scopes, + get_conversation_id, + get_delivery_mode, +) + +__all__ = [ + "attributes", + "agents_telemetry", + "format_scopes", + "get_conversation_id", + "get_delivery_mode", + "AttributeMap", + "BaseSpanWrapper", + "SimpleSpanWrapper", + "SERVICE_NAME", + "SERVICE_VERSION", + "RESOURCE", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py new file mode 100644 index 00000000..c606d4d0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_PROCESS = "agents.adapter.process" +SPAN_SEND_ACTIVITIES = "agents.adapter.sendActivities" +SPAN_UPDATE_ACTIVITY = "agents.adapter.updateActivity" +SPAN_DELETE_ACTIVITY = "agents.adapter.deleteActivity" +SPAN_CONTINUE_CONVERSATION = "agents.adapter.continueConversation" +SPAN_CREATE_CONNECTOR_CLIENT = "agents.adapter.createConnectorClient" +SPAN_CREATE_USER_TOKEN_CLIENT = "agents.adapter.createUserTokenClient" + +METRIC_ADAPTER_PROCESS_DURATION = "agents.adapter.process.duration" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py new file mode 100644 index 00000000..18101f10 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +adapter_process_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_ADAPTER_PROCESS_DURATION, + "ms", + description="Duration of adapter processing in milliseconds", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py new file mode 100644 index 00000000..89bf755c --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -0,0 +1,136 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span +from microsoft_agents.activity import Activity + +from microsoft_agents.hosting.core.telemetry import ( + AttributeMap, + SimpleSpanWrapper, + get_conversation_id, + get_delivery_mode, + format_scopes, + attributes, +) +from . import constants, metrics + +class AdapterProcess(SimpleSpanWrapper): + """Span for processing an incoming activity in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterProcess SpanWrapper.""" + super().__init__(constants.SPAN_PROCESS) + self._activity = activity + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the adapter processing based on the outcome of the span.""" + metrics.adapter_process_duration.record(duration) + + def _get_attributes(self) -> AttributeMap: + return { + attributes.ACTIVITY_TYPE: self._activity.type, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + attributes.ACTIVITY_DELIVERY_MODE: get_delivery_mode(self._activity), + attributes.CONVERSATION_ID: get_conversation_id(self._activity), + attributes.IS_AGENTIC: self._activity.is_agentic_request(), + } + +class AdapterSendActivities(SimpleSpanWrapper): + """Span for sending activities in the adapter.""" + + def __init__(self, activities: list[Activity]): + """Initializes the AdapterSendActivities span.""" + super().__init__(constants.SPAN_SEND_ACTIVITIES) + self._activities = activities + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activities being sent.""" + return { + attributes.ACTIVITY_COUNT: len(self._activities), + attributes.CONVERSATION_ID: ( + get_conversation_id(self._activities[0]) + if self._activities else attributes.UNKNOWN + ), + } + +class AdapterUpdateActivity(SimpleSpanWrapper): + """Span for updating an activity in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterUpdateActivity span.""" + super().__init__(constants.SPAN_UPDATE_ACTIVITY) + self._activity = activity + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being updated.""" + return { + attributes.ACTIVITY_ID: self._activity.id or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id(self._activity), + } + +class AdapterDeleteActivity(SimpleSpanWrapper): + """Span for deleting an activity in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterDeleteActivity span.""" + super().__init__(constants.SPAN_DELETE_ACTIVITY) + self._activity = activity + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being deleted.""" + return { + attributes.ACTIVITY_ID: self._activity.id or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id(self._activity), + } + +class AdapterContinueConversation(SimpleSpanWrapper): + """Span for continuing a conversation in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterContinueConversation span.""" + super().__init__(constants.SPAN_CONTINUE_CONVERSATION) + self._activity = activity + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the conversation being continued.""" + return { + attributes.APP_ID: self._activity.recipient.id if self._activity.recipient else attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id(self._activity), + attributes.IS_AGENTIC_REQUEST: self._activity.is_agentic_request(), + } + +class AdapterCreateUserTokenClient(SimpleSpanWrapper): + """Span for creating a user token in the adapter.""" + + def __init__(self, token_service_endpoint: str, scopes: list[str] | None): + """Initializes the AdapterCreateUserToken span.""" + super().__init__(constants.SPAN_CREATE_USER_TOKEN_CLIENT) + self._token_service_endpoint = token_service_endpoint + self._scopes = scopes + + def _get_attributes(self) -> AttributeMap: + """Starts the AdapterCreateUserToken span, and sets attributes related to the user token being created.""" + return { + attributes.TOKEN_SERVICE_ENDPOINT: self._token_service_endpoint, + attributes.AUTH_SCOPES: format_scopes(self._scopes), + } + +class AdapterCreateConnectorClient(SimpleSpanWrapper): + """Span for creating a connector client in the adapter.""" + + def __init__(self, service_url: str, scopes: list[str] | None, is_agentic_request: bool): + """Initializes the AdapterCreateConnectorClient span.""" + super().__init__(constants.SPAN_CREATE_CONNECTOR_CLIENT) + self._service_url = service_url + self._scopes = scopes + self._is_agentic_request = is_agentic_request + + def _get_attributes(self) -> AttributeMap: + """Starts the AdapterCreateConnectorClient span, and sets attributes related to the connector client being created.""" + return { + attributes.SERVICE_URL: self._service_url, + attributes.AUTH_SCOPES: format_scopes(self._scopes), + attributes.IS_AGENTIC_REQUEST: self._is_agentic_request, + } \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py new file mode 100644 index 00000000..80f163a6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +ACTIVITY_DELIVERY_MODE = "activity.delivery_mode" +ACTIVITY_CHANNEL_ID = "activity.channel_id" +ACTIVITY_ID = "activity.id" +ACTIVITY_COUNT = "activities.count" +ACTIVITY_TYPE = "activity.type" + +AGENTIC_USER_ID = "agentic.user_id" +AGENTIC_INSTANCE_ID = "agentic.instance_id" + +APP_ID = "agent.app_id" + +ATTACHMENT_ID = "activity.attachment.id" +ATTACHMENT_COUNT = "activity.attachments.count" + +AUTH_SCOPES = "auth.scopes" +AUTH_TYPE = "auth.method" + +CONVERSATION_ID = "activity.conversation.id" + +IS_AGENTIC = "is_agentic_request" + +KEY_COUNT = "storage.keys.count" + +ROUTE_AUTHORIZED = "route.authorized" +ROUTE_IS_INVOKE = "route.is_invoke" +ROUTE_IS_AGENTIC = "route.is_agentic" +ROUTE_MATCHED = "route.matched" + +SERVICE_URL = "service_url" + +TOKEN_SERVICE_ENDPOINT = "agents.token_service.endpoint" + +# for missing values +UNKNOWN = "unknown" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py new file mode 100644 index 00000000..71b29dcd --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import resource +from ._agents_telemetry import agents_telemetry +from .type_defs import AttributeMap, SpanCallback +from .simple_span_wrapper import SimpleSpanWrapper +from .base_span_wrapper import BaseSpanWrapper +from .resource import SERVICE_NAME, SERVICE_VERSION, RESOURCE + +__all__ = [ + "agents_telemetry", + "resource", + "AttributeMap", + "SpanCallback", + "SimpleSpanWrapper", + "BaseSpanWrapper", + "SERVICE_NAME", + "SERVICE_VERSION", + "RESOURCE", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py new file mode 100644 index 00000000..d03b602a --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import time +import logging +from collections.abc import Iterator + +from contextlib import contextmanager + +from opentelemetry.metrics import Meter +from opentelemetry import metrics, trace +from opentelemetry.trace import Tracer, Span + +from microsoft_agents.activity import TurnContextProtocol + +from .. import core +from .type_defs import SpanCallback + +logger = logging.getLogger(__name__) + +class _AgentsTelemetry: + + def __init__(self): + """Initializes the AgentsTelemetry instance with the given tracer and meter, or creates new ones if not provided + + :param tracer: Optional OpenTelemetry Tracer instance to use for creating spans. If not provided, a new tracer will be created with the service name and version from constants. + :param meter: Optional OpenTelemetry Meter instance to use for recording metrics. If not provided, a new meter will be created with the service name and version from constants. + """ + self._tracer = trace.get_tracer( + core.SERVICE_NAME, core.SERVICE_VERSION + ) + self._meter = metrics.get_meter( + core.SERVICE_NAME, core.SERVICE_VERSION + ) + + @property + def tracer(self) -> Tracer: + """Returns the OpenTelemetry tracer instance for creating spans""" + return self._tracer + + @property + def meter(self) -> Meter: + """Returns the OpenTelemetry meter instance for recording metrics""" + return self._meter + + def _extract_attributes_from_context( + self, turn_context: TurnContextProtocol + ) -> dict: + """Helper method to extract common attributes from the TurnContext for span and metric recording""" + + # This can be expanded to extract common attributes for spans and metrics from the context + attributes = {} + attributes["activity.type"] = turn_context.activity.type + attributes["agent.is_agentic"] = turn_context.activity.is_agentic_request() + if turn_context.activity.from_property: + attributes["from.id"] = turn_context.activity.from_property.id + if turn_context.activity.recipient: + attributes["recipient.id"] = turn_context.activity.recipient.id + if turn_context.activity.conversation: + attributes["conversation.id"] = turn_context.activity.conversation.id + attributes["channel_id"] = turn_context.activity.channel_id + attributes["message.text.length"] = ( + len(turn_context.activity.text) if turn_context.activity.text else 0 + ) + return attributes + + def set_attributes_from_context(self, span: Span, turn_context: TurnContextProtocol) -> None: + """Extracts attributes from the TurnContext and sets them on the given span + + :param span: The OpenTelemetry span to set attributes on + :param turn_context: The TurnContext to extract attributes from + """ + span.set_attributes(self._extract_attributes_from_context(turn_context)) + + @contextmanager + def start_as_current_span( + self, + span_name: str, + callback: SpanCallback | None = None, + ) -> Iterator[Span]: + """Context manager for starting a timed span that records duration and success/failure status, and invokes a callback with the results + + :param span_name: The name of the span to start + :param callback: Optional callback function that will be called with the span, duration in milliseconds, and any exception that was raised (or None if successful) when the span is ended + :return: An iterator that yields the started span, which will be ended when the context manager exits + """ + + with self._tracer.start_as_current_span(span_name) as span: + + start = time.time() + exception: Exception | None = None + + try: + yield span # execute the operation in the with block + except Exception as e: + span.record_exception(e) + exception = e + finally: + + success = exception is None + + end = time.time() + duration = (end - start) * 1000 # milliseconds + + if success: + span.add_event(f"{span_name} completed", {"duration_ms": duration}) + span.set_status(trace.Status(trace.StatusCode.OK)) + if callback: + callback(span, duration, None) + else: + if callback: + callback(span, duration, exception) + + span.set_status(trace.Status(trace.StatusCode.ERROR)) + raise exception from None # re-raise to ensure it's not swallowed + + +agents_telemetry = _AgentsTelemetry() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py new file mode 100644 index 00000000..4b7f7dd0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +import logging + +from abc import ABC, abstractmethod +from contextlib import ExitStack +from typing import ContextManager +from venv import logger + +from opentelemetry.trace import Span + +logger = logging.getLogger(__name__) + +class BaseSpanWrapper(ABC): + """Wrapper around OTEL spans for SDK-specific telemetry""" + + def __init__(self): + self._span: Span | None = None + self._active: bool = False + + self._exit_stack = ExitStack() + + @property + def otel_span(self) -> Span | None: + """Returns the underlying OTEL span if it is active, or None if the span has not been started or has already ended. This can be used to access OTEL-specific functionality or attributes of the span when needed, while still providing a higher-level abstraction through the BaseSpanWrapper class.""" + if self._span is None: + raise RuntimeError("BaseSpanWrapper has not been started yet") + return self._span + + @property + def active(self) -> bool: + """Indicates whether the BaseSpanWrapper is currently active. This can be used to prevent operations on an inactive BaseSpanWrapper, and to check the BaseSpanWrapper's lifecycle state.""" + return self._active + + @abstractmethod + def _start_span(self) -> ContextManager[Span]: + """Abstract method that must be implemented by subclasses to define how the BaseSpanWrapper is started and what attributes are set on the BaseSpanWrapper. This method should return a context manager that yields the started BaseSpanWrapper, allowing the base BaseSpanWrapper class to manage the BaseSpanWrapper's lifecycle and ensure proper cleanup when the BaseSpanWrapper is ended.""" + raise NotImplementedError + + @staticmethod + def _log_lifespan_error(desc: str) -> None: + """Helper method to log a warning when an operation is attempted on an inactive BaseSpanWrapper. This can be used in methods that require an active BaseSpanWrapper to indicate potential misuse of the BaseSpanWrapper lifecycle.""" + logger.warning("Attempting to perform an operation on an inactive BaseSpanWrapper. This may indicate a bug in the telemetry implementation or misuse of the BaseSpanWrapper lifecycle.") + logger.warning("Description: %s", desc) + + def __enter__(self) -> BaseSpanWrapper: + """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining. This method should check if the BaseSpanWrapper is already active and log a warning if an attempt is made to start an already active BaseSpanWrapper, to help identify potential issues with BaseSpanWrapper lifecycle management.""" + if self._active: + BaseSpanWrapper._log_lifespan_error("Attempting to start a BaseSpanWrapper that is already active.") + + self._span = self._exit_stack.enter_context(self._start_span()) + self._active = True + + return self + + def start(self) -> BaseSpanWrapper: + """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining""" + return self.__enter__() + + def __exit__(self, exc_type, exc_val, exc_tb): + """Stops the BaseSpanWrapper if it is active, and logs a warning if an attempt is made to stop a BaseSpanWrapper that is not active. This ensures that BaseSpanWrappers are properly cleaned up and that potential issues with BaseSpanWrapper lifecycle management are logged for debugging purposes.""" + if self._active: + self._exit_stack.__exit__(exc_type, exc_val, exc_tb) + self._span = None + self._active = False + else: + BaseSpanWrapper._log_lifespan_error("BaseSpanWrapper is not active and cannot be exited") + + def end(self) -> None: + """Stops the BaseSpanWrapper if it is active""" + self.__exit__(None, None, None) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py new file mode 100644 index 00000000..a95449c9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +from opentelemetry.sdk.resources import Resource + +SERVICE_NAME = "microsoft_agents" +SERVICE_VERSION = "1.0.0" + +RESOURCE = Resource.create( + { + "service.name": SERVICE_NAME, + "service.version": SERVICE_VERSION, + "service.instance.id": os.getenv("HOSTNAME", "unknown"), + "telemetry.sdk.language": "python", + } +) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py new file mode 100644 index 00000000..6eea4ad1 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC +from collections.abc import Iterator +from contextlib import contextmanager + +from opentelemetry.trace import Span + +from ._agents_telemetry import agents_telemetry +from .base_span_wrapper import BaseSpanWrapper +from .type_defs import AttributeMap + +class SimpleSpanWrapper(BaseSpanWrapper, ABC): + """Simple implementation of the BaseSpanWrapper that can be used when no additional attributes or functionality are needed on the span beyond what is provided by the base BaseSpanWrapper class. This can be used as a simple wrapper around an OTEL span for cases where no SDK-specific telemetry is needed, while still providing the benefits of the BaseSpanWrapper abstraction and lifecycle management.""" + + def __init__(self, span_name: str): + super().__init__() + self._span_name = span_name + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This can be overridden by subclasses to provide custom attributes for the span based on the context in which it is being used.""" + return {} + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This can be overridden by subclasses to provide custom logic for recording metrics or handling errors based on the outcome of the span.""" + pass + + @contextmanager + def _start_span(self) -> Iterator[Span]: + """Starts a basic OTEL span with the given name and no additional attributes.""" + with agents_telemetry.start_as_current_span(self._span_name, callback=self._callback) as span: + if span is not None: + attributes = self._get_attributes() + if attributes: + span.set_attributes(attributes) + yield span \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py new file mode 100644 index 00000000..d532f88d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py @@ -0,0 +1,7 @@ +from typing import Mapping, Callable + +from opentelemetry.util.types import AttributeValue +from opentelemetry.trace import Span + +AttributeMap = Mapping[str, AttributeValue] +SpanCallback = Callable[[Span, float, Exception | None], None] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py new file mode 100644 index 00000000..c94dee7c --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py @@ -0,0 +1,11 @@ +# Span operation names + +SPAN_TURN_SEND_ACTIVITY = "agents.turn.sendActivity" +SPAN_TURN_UPDATE_ACTIVITY = "agents.turn.updateActivity" +SPAN_TURN_DELETE_ACTIVITY = "agents.turn.deleteActivity" + + +METRIC_ACTIVITIES_RECEIVED = "agents.activities.received" +METRIC_ACTIVITIES_SENT = "agents.activities.sent" +METRIC_ACTIVITIES_UPDATED = "agents.activities.updated" +METRIC_ACTIVITIES_DELETED = "agents.activities.deleted" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/metrics.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py new file mode 100644 index 00000000..a9fd0aa6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from microsoft_agents.activity import TurnContextProtocol +from microsoft_agents.hosting.core.telemetry import ( + AttributeMap, + SimpleSpanWrapper, + attributes, + get_conversation_id +) +from . import constants + +class _TurnContextSpanWrapper(SimpleSpanWrapper): + """Base span wrapper for TurnContext operations""" + + def __init__(self, span_name: str, turn_context: TurnContextProtocol): + """Initializes the span wrapper with the given span name and turn context.""" + super().__init__(span_name) + self._turn_context = turn_context + + def _get_attributes(self) -> AttributeMap: + activity = self._turn_context.activity + return { + attributes.CONVERSATION_ID: get_conversation_id(activity), + } + +class TurnContextSendActivity(_TurnContextSpanWrapper): + """Span wrapper for sending an activity within a turn context.""" + + def __init__(self, turn_context: TurnContextProtocol): + super().__init__(constants.SPAN_TURN_SEND_ACTIVITY, turn_context) + +class TurnContextUpdateActivity(_TurnContextSpanWrapper): + """Span wrapper for updating an activity within a turn context.""" + + def __init__(self, turn_context: TurnContextProtocol): + super().__init__(constants.SPAN_TURN_UPDATE_ACTIVITY, turn_context) + +class TurnContextDeleteActivity(_TurnContextSpanWrapper): + """Span wrapper for deleting an activity within a turn context.""" + + def __init__(self, turn_context: TurnContextProtocol): + super().__init__(constants.SPAN_TURN_DELETE_ACTIVITY, turn_context) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py new file mode 100644 index 00000000..d76dd77d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.activity import Activity, DeliveryModes + +from .attributes import UNKNOWN + +def format_scopes(scopes: list[str] | None) -> str: + """Formats a list of scopes into a string for telemetry recording. If the list is None or empty, returns a constant value indicating unknown scopes.""" + if not scopes: + return UNKNOWN + return ",".join(scopes) + +def get_conversation_id(activity: Activity) -> str: + """Extracts the conversation ID from the given activity. If the conversation ID cannot be found, returns a constant value indicating unknown conversation ID.""" + return activity.conversation.id if activity.conversation else UNKNOWN + + +def get_delivery_mode(activity: Activity) -> str: + """Extracts the delivery mode from the given activity. If the delivery mode cannot be found, returns a constant value indicating unknown delivery mode.""" + if activity.delivery_mode: + if isinstance(activity.delivery_mode, DeliveryModes): + return activity.delivery_mode.value + else: + return activity.delivery_mode + return UNKNOWN \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index a3320b4b..daf6231b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -20,6 +20,7 @@ ) from microsoft_agents.activity.entity.entity_types import EntityTypes from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity +from microsoft_agents.hosting.core.telemetry.turn_context import spans class TurnContext(TurnContextProtocol): @@ -207,8 +208,10 @@ async def send_activity( if speak: activity_or_text.speak = speak - result = await self.send_activities([activity_or_text]) - return result[0] if result else None + with spans.TurnContextSendActivity(self): + + result = await self.send_activities([activity_or_text]) + return result[0] if result else None async def send_activities( self, activities: list[Activity] @@ -268,13 +271,14 @@ async def update_activity(self, activity: Activity): :param activity: :return: """ - reference = self.activity.get_conversation_reference() + with spans.TurnContextUpdateActivity(self): + reference = self.activity.get_conversation_reference() - return await self._emit( - self._on_update_activity, - TurnContext.apply_conversation_reference(activity, reference), - self.adapter.update_activity(self, activity), - ) + return await self._emit( + self._on_update_activity, + TurnContext.apply_conversation_reference(activity, reference), + self.adapter.update_activity(self, activity), + ) async def delete_activity(self, id_or_reference: str | ConversationReference): """ @@ -282,16 +286,17 @@ async def delete_activity(self, id_or_reference: str | ConversationReference): :param id_or_reference: :return: """ - if isinstance(id_or_reference, str): - reference = self.activity.get_conversation_reference() - reference.activity_id = id_or_reference - else: - reference = id_or_reference - return await self._emit( - self._on_delete_activity, - reference, - self.adapter.delete_activity(self, reference), - ) + with spans.TurnContextDeleteActivity(self): + if isinstance(id_or_reference, str): + reference = self.activity.get_conversation_reference() + reference.activity_id = id_or_reference + else: + reference = id_or_reference + return await self._emit( + self._on_delete_activity, + reference, + self.adapter.delete_activity(self, reference), + ) def on_send_activities(self, handler) -> "TurnContext": """ diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index b1da90cf..84d43898 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -17,5 +17,7 @@ "isodate>=0.6.1", "azure-core>=1.30.0", "python-dotenv>=1.1.1", + "opentelemetry-api>=1.17.0", # TODO -> verify this before commit + "opentelemetry-sdk>=1.17.0", ], ) diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py index a94f81df..41ce1776 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py @@ -12,6 +12,7 @@ HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase +from microsoft_agents.hosting.core.telemetry import agents_telemetry from .agent_http_adapter import AgentHttpAdapter diff --git a/test_samples/otel/dashboard.ps1 b/test_samples/otel/dashboard.ps1 new file mode 100644 index 00000000..de2dd386 --- /dev/null +++ b/test_samples/otel/dashboard.ps1 @@ -0,0 +1 @@ +docker run --rm -it -p 18888:18888 -p 4317:18889 --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:latest \ No newline at end of file diff --git a/test_samples/otel/requirements.txt b/test_samples/otel/requirements.txt new file mode 100644 index 00000000..879687ff --- /dev/null +++ b/test_samples/otel/requirements.txt @@ -0,0 +1,14 @@ +python-dotenv +aiohttp +microsoft-agents-hosting-aiohttp +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +opentelemetry-instrumentation-aiohttp-server +opentelemetry-instrumentation-aiohttp-client +opentelemetry-instrumentation-requests +opentelemetry-exporter-otlp +opentelemetry-sdk +opentelemetry-api +opentelemetry-instrumentation-logging +opentelemetry-instrumentation \ No newline at end of file diff --git a/test_samples/otel/src/__init__.py b/test_samples/otel/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/otel/src/agent.py b/test_samples/otel/src/agent.py new file mode 100644 index 00000000..037a6e76 --- /dev/null +++ b/test_samples/otel/src/agent.py @@ -0,0 +1,63 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +import sys +import traceback +from dotenv import load_dotenv + +from os import environ +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + Authorization, + AgentApplication, + TurnState, + TurnContext, + MemoryStorage, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.activity import load_configuration_from_env + +load_dotenv() +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) + + +@AGENT_APP.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState): + await context.send_activity( + "Welcome to the empty agent! " + "This agent is designed to be a starting point for your own agent development." + ) + return True + + +@AGENT_APP.message(re.compile(r"^hello$")) +async def on_hello(context: TurnContext, _state: TurnState): + await context.send_activity("Hello!") + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + await context.send_activity(f"you said: {context.activity.text}") + + +@AGENT_APP.error +async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") diff --git a/test_samples/otel/src/env.TEMPLATE b/test_samples/otel/src/env.TEMPLATE new file mode 100644 index 00000000..b7b556f9 --- /dev/null +++ b/test_samples/otel/src/env.TEMPLATE @@ -0,0 +1,14 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO + +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_EXPORTER_OTLP_INSECURE=true + +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=".*" +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE=".*" + +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST=".*" +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE=".*" \ No newline at end of file diff --git a/test_samples/otel/src/main.py b/test_samples/otel/src/main.py new file mode 100644 index 00000000..bfd1ce41 --- /dev/null +++ b/test_samples/otel/src/main.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .telemetry import configure_otel_providers + +configure_otel_providers(service_name="quickstart_agent") + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), +) diff --git a/test_samples/otel/src/requirements.txt b/test_samples/otel/src/requirements.txt new file mode 100644 index 00000000..879687ff --- /dev/null +++ b/test_samples/otel/src/requirements.txt @@ -0,0 +1,14 @@ +python-dotenv +aiohttp +microsoft-agents-hosting-aiohttp +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +opentelemetry-instrumentation-aiohttp-server +opentelemetry-instrumentation-aiohttp-client +opentelemetry-instrumentation-requests +opentelemetry-exporter-otlp +opentelemetry-sdk +opentelemetry-api +opentelemetry-instrumentation-logging +opentelemetry-instrumentation \ No newline at end of file diff --git a/test_samples/otel/src/start_server.py b/test_samples/otel/src/start_server.py new file mode 100644 index 00000000..b781b208 --- /dev/null +++ b/test_samples/otel/src/start_server.py @@ -0,0 +1,47 @@ +from os import environ +import logging + +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.aiohttp import ( + start_agent_process, + CloudAdapter, +) +from aiohttp.web import Request, Response, Application, run_app + +logger = logging.getLogger(__name__) + + +def start_server( + agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration +): + async def entry_point(req: Request) -> Response: + + logger.info("Request received at /api/messages endpoint.") + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + + return await start_agent_process( + req, + agent, + adapter, + ) + + APP = Application(middlewares=[]) + APP.router.add_post("/api/messages", entry_point) + # async def health(_req: Request) -> Response: + # return json_response( + # { + # "status": "ok", + # "content": "Healthy" + # } + # ) + # APP.router.add_get("/health", health) + + APP["agent_configuration"] = auth_configuration + APP["agent_app"] = agent_application + APP["adapter"] = agent_application.adapter + + try: + run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + except Exception as error: + raise error diff --git a/test_samples/otel/src/telemetry.py b/test_samples/otel/src/telemetry.py new file mode 100644 index 00000000..4b4e2ccc --- /dev/null +++ b/test_samples/otel/src/telemetry.py @@ -0,0 +1,112 @@ +import logging +import os +import requests + +import aiohttp +from opentelemetry import metrics, trace +from opentelemetry.trace import Span +from opentelemetry._logs import set_logger_provider +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor + +from opentelemetry.instrumentation.aiohttp_server import AioHttpServerInstrumentor +from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor +from opentelemetry.instrumentation.requests import RequestsInstrumentor + +def instrument_libraries(): + """Instrument libraries for OpenTelemetry.""" + + # ## + # # instrument aiohttp server -> causes problems + # ## + # AioHttpServerInstrumentor().instrument(tracer_provider=tracer_provider) + + # ## + # # instrument aiohttp client + # ## + def aiohttp_client_request_hook( + span: Span, params: aiohttp.TraceRequestStartParams + ): + if span and span.is_recording(): + span.set_attribute("http.url", str(params.url)) + + def aiohttp_client_response_hook( + span: Span, + params: aiohttp.TraceRequestEndParams | aiohttp.TraceRequestExceptionParams, + ): + if span and span.is_recording(): + span.set_attribute("http.url", str(params.url)) + + AioHttpClientInstrumentor().instrument( + request_hook=aiohttp_client_request_hook, + response_hook=aiohttp_client_response_hook, + ) + + # + # instrument requests library + ## + def requests_request_hook(span: Span, request: requests.Request): + if span and span.is_recording(): + span.set_attribute("http.url", request.url) + + def requests_response_hook( + span: Span, request: requests.Request, response: requests.Response + ): + if span and span.is_recording(): + span.set_attribute("http.url", response.url) + + RequestsInstrumentor().instrument( + request_hook=requests_request_hook, response_hook=requests_response_hook + ) + +def configure_otel_providers(service_name: str = "app"): + """Configure OpenTelemetry for FastAPI application.""" + + # Create resource with service name + resource = Resource.create( + { + "service.name": service_name, + "service.version": "1.0.0", + "service.instance.id": os.getenv("HOSTNAME", "unknown"), + "telemetry.sdk.language": "python", + } + ) + + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317/") + + # Configure Tracing + tracer_provider = TracerProvider(resource=resource) + tracer_provider.add_span_processor( + SimpleSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) + ) + trace.set_tracer_provider(tracer_provider) + + # Configure Metrics + metric_reader = PeriodicExportingMetricReader( + OTLPMetricExporter(endpoint=endpoint) + ) + meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader]) + metrics.set_meter_provider(meter_provider) + + # Configure Logging + logger_provider = LoggerProvider(resource=resource) + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint)) + ) + set_logger_provider(logger_provider) + + # Add logging handler + handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider) + logging.getLogger().addHandler(handler) + + logging.getLogger().info("OpenTelemetry providers configured with endpoint: %s", endpoint) + + instrument_libraries() \ No newline at end of file diff --git a/tests/hosting_core/telemetry/__init__.py b/tests/hosting_core/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/telemetry/test_agents_telemetry.py b/tests/hosting_core/telemetry/test_agents_telemetry.py new file mode 100644 index 00000000..f21b5f92 --- /dev/null +++ b/tests/hosting_core/telemetry/test_agents_telemetry.py @@ -0,0 +1,179 @@ +import pytest +from types import SimpleNamespace + +from opentelemetry import trace, metrics +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.telemetry import ( + agents_telemetry, + constants, + spans as _spans, +) + +@pytest.fixture(scope="module") +def test_telemetry(): + """Set up fresh in-memory exporter for testing.""" + exporter = InMemorySpanExporter() + metric_reader = InMemoryMetricReader() + + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + trace.set_tracer_provider(tracer_provider) + + meter_provider = MeterProvider([metric_reader]) + + metrics.set_meter_provider(meter_provider) + + yield exporter, metric_reader + + exporter.clear() + tracer_provider.shutdown() + meter_provider.shutdown() + +@pytest.fixture(scope="function") +def test_exporter(test_telemetry): + """Provide the in-memory span exporter for each test.""" + exporter, _ = test_telemetry + return exporter + +@pytest.fixture(scope="function") +def test_metric_reader(test_telemetry): + """Provide the in-memory metric reader for each test.""" + _, metric_reader = test_telemetry + return metric_reader + +@pytest.fixture(autouse=True, scope="function") +def clear(test_exporter, test_metric_reader): + """Clear spans before each test to ensure test isolation.""" + test_exporter.clear() + test_metric_reader.force_flush() + + +def _build_turn_context(mocker): + activity = SimpleNamespace( + type="message", + id="activity-1", + from_property=SimpleNamespace(id="user-1"), + recipient=SimpleNamespace(id="bot-1"), + conversation=SimpleNamespace(id="conversation-1"), + channel_id="msteams", + text="Hello!", + ) + activity.is_agentic_request = lambda: False + + context = mocker.Mock(spec=TurnContext) + context.activity = activity + return context + + +def _find_metric(metrics_data, metric_name): + for resource_metric in metrics_data.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + if metric.name == metric_name: + return metric + return None + + +def _sum_counter(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.value + return total + + +def _sum_hist_count(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.count + return total + + +def test_start_as_current_span(mocker, test_exporter): + """Test start_as_current_span creates a span with context attributes.""" + context = _build_turn_context(mocker) + + with agents_telemetry.start_as_current_span("test_span", context): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "test_span" + + attributes = spans[0].attributes + assert attributes["activity.type"] == "message" + assert attributes["agent.is_agentic"] is False + assert attributes["from.id"] == "user-1" + assert attributes["recipient.id"] == "bot-1" + assert attributes["conversation.id"] == "conversation-1" + assert attributes["channel_id"] == "msteams" + assert attributes["message.text.length"] == 6 + +def test_start_timed_span(mocker, test_exporter): + """Test start_timed_span records success status and callback payload.""" + context = _build_turn_context(mocker) + callback = mocker.Mock() + + with agents_telemetry.start_timed_span( + "test_timed_span", + context, + callback=callback, + ): + pass + + finished_spans = test_exporter.get_finished_spans() + assert len(finished_spans) == 1 + + finished_span = finished_spans[0] + assert finished_span.name == "test_timed_span" + assert finished_span.status.status_code == trace.StatusCode.OK + + completion_events = [ + event for event in finished_span.events if event.name == "test_timed_span completed" + ] + assert len(completion_events) == 1 + assert completion_events[0].attributes["duration_ms"] >= 0 + + callback.assert_called_once() + callback_span, duration_ms, callback_exception = callback.call_args.args + assert callback_span.name == "test_timed_span" + assert duration_ms >= 0 + assert callback_exception is None + + +def test_start_span_app_on_turn(mocker, test_exporter, test_metric_reader): + """Test agent_turn_operation records span and turn metrics.""" + context = _build_turn_context(mocker) + + with _spans.start_span_app_on_turn(context): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_APP_ON_TURN + + metric_data = test_metric_reader.get_metrics_data() + turn_total = _sum_counter(_find_metric(metric_data, constants.METRIC_TURN_TOTAL)) + turn_duration_count = _sum_hist_count( + _find_metric(metric_data, constants.METRIC_TURN_DURATION) + ) + + assert turn_total == 1 + assert turn_duration_count == 1