diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..5bd7bcc99 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,75 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/src/Squad.Agents.AI" + schedule: + interval: "weekly" + day: "monday" + time: "08:00" + timezone: "Etc/UTC" + open-pull-requests-limit: 5 + labels: ["dependencies", "squad:belanna"] + commit-message: + prefix: "deps" + groups: + microsoft-ai-majors: + patterns: + - "Microsoft.Agents.AI*" + - "Microsoft.Extensions.AI" + update-types: + - "major" + microsoft-packages: + patterns: + - "Microsoft.*" + update-types: + - "minor" + - "patch" + ignore: + - dependency-name: "OpenTelemetry*" + update-types: + - "version-update:semver-major" + + - package-ecosystem: "nuget" + directory: "/test/Squad.Agents.AI.Tests" + schedule: + interval: "weekly" + day: "monday" + time: "08:00" + timezone: "Etc/UTC" + open-pull-requests-limit: 5 + labels: ["dependencies", "squad:belanna"] + commit-message: + prefix: "deps" + groups: + microsoft-ai-majors: + patterns: + - "Microsoft.Agents.AI*" + - "Microsoft.Extensions.AI" + update-types: + - "major" + microsoft-packages: + patterns: + - "Microsoft.*" + update-types: + - "minor" + - "patch" + ignore: + - dependency-name: "OpenTelemetry*" + update-types: + - "version-update:semver-major" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "08:00" + timezone: "Etc/UTC" + open-pull-requests-limit: 5 + labels: ["dependencies", "squad:belanna"] + commit-message: + prefix: "ci" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/squad-agents-ai-ci.yml b/.github/workflows/squad-agents-ai-ci.yml new file mode 100644 index 000000000..cdd8b5081 --- /dev/null +++ b/.github/workflows/squad-agents-ai-ci.yml @@ -0,0 +1,91 @@ +name: Squad.Agents.AI CI + +on: + pull_request: + paths: + - 'src/Squad.Agents.AI/**' + - 'test/Squad.Agents.AI.Tests/**' + - 'src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/**' + - '.github/workflows/squad-agents-ai-ci.yml' + - 'Directory.*.props' + - 'Directory.*.json' + - 'global.json' + - 'nuget.config' + push: + branches: [main] + paths: + - 'src/Squad.Agents.AI/**' + - 'test/Squad.Agents.AI.Tests/**' + - 'src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/**' + - '.github/workflows/squad-agents-ai-ci.yml' + - 'Directory.*.props' + - 'Directory.*.json' + - 'global.json' + - 'nuget.config' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + dotnet: + name: .NET ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore package + run: dotnet restore src/Squad.Agents.AI/Squad.Agents.AI.csproj -p:NuGetAudit=true + + - name: Restore tests + run: dotnet restore test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -p:NuGetAudit=true + + - name: Restore sample + run: dotnet restore src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Squad.Agents.AI.Sample.csproj -p:NuGetAudit=true + + - name: Build package + run: dotnet build src/Squad.Agents.AI/Squad.Agents.AI.csproj --no-restore -c Release + + - name: Build tests + run: dotnet build test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj --no-restore -c Release + + - name: Build sample + run: dotnet build src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Squad.Agents.AI.Sample.csproj --no-restore -c Release + + - name: Test + run: dotnet test test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj --no-build -c Release --logger trx --results-directory TestResults + + - name: Pack + run: dotnet pack src/Squad.Agents.AI/Squad.Agents.AI.csproj --no-build -c Release -o nupkgs + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: squad-agents-ai-testresults-${{ matrix.os }} + path: TestResults + if-no-files-found: ignore + + - name: Upload NuGet packages + uses: actions/upload-artifact@v4 + with: + name: squad-agents-ai-nupkgs-${{ matrix.os }} + path: nupkgs + if-no-files-found: error diff --git a/.github/workflows/squad-agents-ai-release.yml b/.github/workflows/squad-agents-ai-release.yml new file mode 100644 index 000000000..22c04533c --- /dev/null +++ b/.github/workflows/squad-agents-ai-release.yml @@ -0,0 +1,160 @@ +# Requires repo secret NUGET_API_KEY. Generate at nuget.org → API Keys → Create. +# +# ## Failure recovery +# Publishing is branch-driven: dev creates prerelease NuGet packages, main creates stable NuGet +# packages, and workflow_dispatch remains an escape hatch. PACKAGE_VERSION is resolved once +# before build/test/pack/push and the nupkgs/* artifacts are uploaded before the registry write. +# If a run is interrupted after NuGet accepted the package but before the workflow completes, +# rerun the same workflow/run: NuGet package versions are immutable and `--skip-duplicate` +# turns already-published packages into no-ops while retrying anything not yet published. +# `cancel-in-progress: false` prevents a recovery rerun from canceling an in-flight publish. +# Dev prereleases use github.run_number in the prerelease field because it is monotonically +# increasing per workflow and participates in NuGet/SemVer ordering; reruns keep the same +# version intentionally, and `--skip-duplicate` makes that safe. + +name: Squad.Agents.AI NuGet Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Optional NuGet package version override, for example 0.1.0-preview.123' + required: false + type: string + push: + branches: + - dev + - main + paths: + - 'src/Squad.Agents.AI/**' + - 'test/Squad.Agents.AI.Tests/**' + - 'Directory.*.props' + - 'global.json' + +permissions: + contents: read + +jobs: + publish: + name: Pack and publish NuGet package + runs-on: ubuntu-latest + concurrency: + group: squad-agents-ai-release-${{ github.ref }} + cancel-in-progress: false + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Resolve package version + id: version + shell: bash + run: | + set -euo pipefail + + project_file="src/Squad.Agents.AI/Squad.Agents.AI.csproj" + csproj_version="$(sed -nE 's/^[[:space:]]*([^<]+)<\/Version>[[:space:]]*$/\1/p' "${project_file}" | head -n 1)" + manual_version="${{ inputs.version }}" + version_source="" + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && -n "${manual_version}" ]]; then + package_version="${manual_version}" + version_source="workflow_dispatch input" + else + if [[ -z "${csproj_version}" ]]; then + echo "::error::Could not find in ${project_file}." + exit 1 + fi + + case "${GITHUB_REF_NAME}" in + main) + # Stable releases mirror the Squad CLI's release workflow: the maintainer bumps + # the source version before merge, and the workflow publishes that exact version. + package_version="${csproj_version}" + version_source="${project_file}" + if [[ "${package_version}" == *-* ]]; then + echo "::error::main publishes stable packages; ${project_file} has prerelease version '${package_version}'. Bump it before merging." + exit 1 + fi + ;; + dev) + # The Squad CLI derives prereleases from the source version plus commit identity. + # For NuGet, build metadata does not give consumers monotonic prerelease ordering, + # so use github.run_number in the prerelease field instead. + stable_base="${csproj_version%%+*}" + stable_base="${stable_base%%-*}" + package_version="${stable_base}-preview.${GITHUB_RUN_NUMBER}" + version_source="${project_file} + github.run_number" + ;; + *) + echo "::error::workflow_dispatch without a version input must run on dev or main; current ref is '${GITHUB_REF_NAME}'." + exit 1 + ;; + esac + fi + + if [[ -z "${package_version}" ]]; then + echo "::error::PACKAGE_VERSION resolved to empty." + exit 1 + fi + + if [[ ! "${package_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::PACKAGE_VERSION '${package_version}' from ${version_source} is not a SemVer 2.0 version." + exit 1 + fi + + echo "PACKAGE_VERSION=${package_version}" >> "${GITHUB_OUTPUT}" + echo "Resolved PACKAGE_VERSION=${package_version} (${version_source})" + + - name: Verify NuGet API key + shell: bash + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + set -euo pipefail + if [[ -z "${NUGET_API_KEY}" ]]; then + echo "::error::Missing repo secret NUGET_API_KEY. Generate at nuget.org → API Keys → Create, then add it as a repository secret." + exit 1 + fi + + - name: Restore package + run: dotnet restore src/Squad.Agents.AI/Squad.Agents.AI.csproj + + - name: Restore tests + run: dotnet restore test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj + + - name: Build package + run: dotnet build src/Squad.Agents.AI/Squad.Agents.AI.csproj -c Release --no-restore -p:Version=${{ steps.version.outputs.PACKAGE_VERSION }} + + - name: Build tests + run: dotnet build test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -c Release --no-restore -p:Version=${{ steps.version.outputs.PACKAGE_VERSION }} + + - name: Test + run: dotnet test test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj -c Release --no-build + + - name: Pack + run: dotnet pack src/Squad.Agents.AI/Squad.Agents.AI.csproj -c Release --no-build -o nupkgs -p:Version=${{ steps.version.outputs.PACKAGE_VERSION }} + + - name: Upload NuGet package artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: squad-agents-ai-nupkgs-${{ steps.version.outputs.PACKAGE_VERSION }} + path: nupkgs/* + if-no-files-found: ignore + + - name: Push to NuGet.org + shell: bash + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push nupkgs/*.nupkg --source https://api.nuget.org/v3/index.json --api-key "${NUGET_API_KEY}" --skip-duplicate diff --git a/.gitignore b/.gitignore index 8cfd30156..4cb4dbff8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,8 @@ docs/tests/screenshots/ # Squad: PR-1200 picard local artifacts (do not commit) .pr-body-new.md .followup-issue-body.md + +# .NET build output +bin/ +obj/ +artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb2fbc6a..81f25a9e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,14 @@ First stable release since v0.9.4 (April 25). Consumes 97 changesets (sdk: 50, c ### Internal / Tests / Docs - (cli) docs: point README migration guide links to the published docs site +## [0.1.0-preview] - 2026-06-02 + +### Added — Squad.Agents.AI NuGet preview +- Added `Squad.Agents.AI`, a `net10.0` Microsoft Agent Framework package that exposes a Squad team as an `AIAgent`. +- Public surface: `SquadAgent`, `SquadAgentOptions`, `SquadConnectionFactory`, and `SquadServiceCollectionExtensions`. +- Added DI registration, PATH/URI connection-string parsing, GitHub token/provider options, and XML documentation for the public preview surface. +- Added README documentation for the `Squad.Agents.AI` package in `src/Squad.Agents.AI/README.md`. + ## [0.9.0] - 2026-03-23 ### Added — Personal Squad Governance Layer diff --git a/README.md b/README.md index 5ee2e61ec..b2ab79a3d 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ Squad proposes a team — each member named from a persistent thematic cast. You --- +## .NET package preview + +Building a .NET app that should call a Squad team as a Microsoft Agent Framework agent? `Squad.Agents.AI` is a preview NuGet package under [`src/Squad.Agents.AI`](src/Squad.Agents.AI/README.md). It registers a Squad-backed `AIAgent` in DI and targets early `0.1.0-preview` consumers. + ## Upgrading Upgrading Squad is a two-step process. diff --git a/Squad.Agents.AI.slnx b/Squad.Agents.AI.slnx new file mode 100644 index 000000000..907725a98 --- /dev/null +++ b/Squad.Agents.AI.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Squad.Agents.AI/README.md b/src/Squad.Agents.AI/README.md new file mode 100644 index 000000000..a7905c16d --- /dev/null +++ b/src/Squad.Agents.AI/README.md @@ -0,0 +1,266 @@ +# Squad.Agents.AI + +> **Preview package.** `Squad.Agents.AI` is a `0.1.0-preview` NuGet package for early adopters. It targets `net10.0` and depends on preview Microsoft Agent Framework / GitHub Copilot SDK packages, so APIs may change before a stable release. + +## What it does + +`Squad.Agents.AI` exposes a Squad team as a Microsoft Agent Framework `AIAgent`. `SquadAgent` composes the Squad CLI through the GitHub Copilot SDK, delegates MAF sessions/runs/streaming to the inner Copilot agent, and gives .NET consumers a DI-friendly wrapper instead of hand-rolling CLI process setup. + +Public surface in this preview: + +- `SquadAgent` — sealed `AIAgent` wrapper over the Copilot-backed inner agent. Supports both non-streaming and streaming via `RunStreamingAsync`. +- `SquadAgentOptions` — team root, CLI path/args, environment, token, logging, instruction settings, and a `ConfigureCopilotClient` delegate for advanced SDK customization. +- `SquadConnectionFactory` — parses PATH or `squad://` connection strings into options. +- `SquadServiceCollectionExtensions` — registers `SquadAgent` and base `AIAgent` in DI, with standard and keyed overloads. + +Repository: + +## Install + +```bash +dotnet add package Squad.Agents.AI --prerelease +``` + +If you are consuming the PR before publish, pack it locally and add the generated package source: + +```bash +dotnet pack src/Squad.Agents.AI/Squad.Agents.AI.csproj -c Release -o nupkgs +dotnet add package Squad.Agents.AI --prerelease --source ./nupkgs +``` + +## Prerequisites + +- .NET 10 SDK. +- GitHub Copilot CLI available on `PATH` (`copilot --version`). +- Squad CLI and an initialized Squad team root; see the [Squad CLI repo](https://github.com/bradygaster/squad). +- GitHub Copilot authentication through the signed-in user. The quickstart below does not require an app key or environment variable. + +## Five-line quickstart + +```csharp +using Microsoft.Agents.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Squad.Agents.AI; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddSquadAgent(o => +{ + o.SquadFolderPath = @"C:\path\to\your\team-root"; +}); + +using var host = builder.Build(); +var squad = host.Services.GetRequiredService(); +var session = await squad.CreateSessionAsync(); +var response = await squad.RunAsync("What can this Squad team do?", session); +Console.WriteLine(response.Text); +``` + +## Streaming + +`SquadAgent` supports streaming via the MAF `RunStreamingAsync` method. The inner Copilot-backed agent streams response updates as they arrive from the CLI process: + +```csharp +var squad = host.Services.GetRequiredService(); +var session = await squad.CreateSessionAsync(); + +await foreach (var update in squad.RunStreamingAsync("Summarize the team.", session)) +{ + Console.Write(update.Text); +} +``` + +## Keyed DI + +Register multiple Squad teams in the same DI container using .NET 8+ keyed services: + +```csharp +builder.Services.AddKeyedSquadAgent("research", o => +{ + o.SquadFolderPath = @"C:\research-team"; +}); + +builder.Services.AddKeyedSquadAgent("platform", o => +{ + o.SquadFolderPath = @"C:\platform-team"; +}); + +// Resolve in a minimal API endpoint: +app.MapGet("/ask-research", async ([FromKeyedServices("research")] SquadAgent agent) => +{ + var session = await agent.CreateSessionAsync(); + var response = await agent.RunAsync("What's the latest?", session); + return response.Text; +}); +``` + +Keyed registrations include `AddKeyedSquadAgent` overloads for service key, optional connection-string name, and lifetime. Keyed and non-keyed registrations can coexist. + +## Advanced: ConfigureCopilotClient (BYOK) + +Use `ConfigureCopilotClient` to customize the underlying `CopilotClientOptions` after Squad applies its standard values. This is the extension point for injecting custom environment variables, providing a BYOK token, or tuning SDK settings: + +```csharp +builder.Services.AddSquadAgent(o => +{ + o.SquadFolderPath = @"C:\team"; + o.ConfigureCopilotClient = clientOpts => + { + // Inject a BYOK token from your own credential store + clientOpts.GitHubToken = myVault.GetSecret("copilot-token"); + + // Add custom environment variables for the CLI process + clientOpts.Environment = new Dictionary + { + ["MY_CUSTOM_VAR"] = "value" + }; + }; +}); +``` + +> **Routing guard:** Squad enforces a hard routing gate — if the delegate changes `Cwd`, `CliPath`, or `CliArgs`, the original values are restored and a warning is logged. Configure these via `SquadAgentOptions` instead. + +## GitHub Copilot authentication + +The default path mirrors the base GitHub Copilot SDK examples: leave `GitHubToken` and `GitHubTokenProvider` unset, then run with a locally signed-in GitHub Copilot user. For local development, sign in before running the app, for example with `gh auth login`, the Copilot CLI sign-in flow, or a Copilot-supported local sign-in that the SDK runtime can access. + +The SDK documents the credential priority order in [Authenticate Copilot SDK](https://github.com/github/copilot-sdk/blob/main/docs/auth/authenticate.md) and the broader [authentication overview](https://github.com/github/copilot-sdk/blob/main/docs/auth/index.md). Explicit tokens and environment variables are supported by the SDK, but they are not required for the minimal happy path. + +Use `GitHubTokenProvider` only when the host owns token retrieval, such as Key Vault, managed identity, or CI secret flow scenarios. Use `GitHubToken` only when you already have a Copilot-supported token in process and cannot use a provider callback. Use `ConfigureCopilotClient` when you need full control over the SDK's `CopilotClientOptions`, including BYOK token injection. + +## Aspire / configuration path + +`AddSquadAgent()` reads `ConnectionStrings:squad` through `IConfiguration.GetConnectionString("squad")`. In environment variables, use `ConnectionStrings__squad`. + +Named registrations select a named connection string. For example, `AddSquadAgent("research")` reads `ConnectionStrings:squad-research`; in environment variables, use `ConnectionStrings__squad-research`. + +Supported connection string forms: + +```text +C:\path\to\team-root +squad://localhost?teamRoot=C%3A%5Cteam&cliPath=C%3A%5Ctools%5Ccopilot.exe&cliArgs=--yolo&env=KEY=value +``` + +Parsed URI query keys: `teamRoot`, `cliPath`, `cwd`, `cliArgs` (semicolon-separated), and `env` (`key=value;key2=value2`). Unknown URI host/protocol values are reserved for future use. + +## Key options + +| Option | Purpose | +|---|---| +| `SquadFolderPath` | Squad team root; PATH connection strings set this and `Cwd`. | +| `CliPath` / `CliArgs` | Override the Copilot CLI executable and add extra CLI flags. | +| `Cwd` | Working directory for the Copilot CLI process; defaults to `SquadFolderPath`. | +| `Environment` | Additional environment variables for the CLI process. Excluded from JSON serialization; token-pattern keys are redacted in `ToString()`. | +| `GitHubToken` | Advanced direct token escape hatch; excluded from JSON serialization and redacted by `ToString()`. | +| `GitHubTokenProvider` | Advanced callback for secure token retrieval; wins over `GitHubToken`. | +| `ConfigureCopilotClient` | Advanced delegate for customizing `CopilotClientOptions`. Routing properties are guarded; see BYOK section. | +| `TraceEvents` | Enables verbose SDK logging and emits a startup warning when enabled. | +| `AgentName` | Display name for the resulting `AIAgent`; defaults to `Squad`. | +| `Instructions` | Optional system instructions passed to the inner Copilot agent. | + +## Security + +- **Credential redaction:** `GitHubToken` and `Environment` values whose keys match `*TOKEN*`, `*KEY*`, `*SECRET*`, `*HMAC*`, `*PASSWORD*`, or `*CREDENTIAL*` are always redacted in `ToString()` output. +- **JSON safety:** `GitHubToken`, `GitHubTokenProvider`, `Environment`, and `ConfigureCopilotClient` are marked `[JsonIgnore]` and will not appear in JSON serialization output. +- **Routing guard:** The `ConfigureCopilotClient` delegate cannot change `Cwd`, `CliPath`, or `CliArgs` — changes are silently restored to prevent routing the agent to an unintended CLI process. +- **TraceEvents warning:** Enabling `TraceEvents` logs a startup warning because verbose SDK traces may include sensitive operational details. +- **Avoid hardcoded tokens:** Never embed tokens in source code. Use `GitHubTokenProvider` for production token retrieval from Key Vault, managed identity, or similar secure stores. + +## Notes for v0.1-preview + +- Default DI lifetime is scoped. An overload accepts any `ServiceLifetime`. +- DI registers both `SquadAgent` and base `AIAgent` (non-keyed). Keyed DI registers both `SquadAgent` and `AIAgent` under the same key. +- Multi-targeting and Aspire telemetry remain candidates for a later preview. +- The package does not validate that `SquadFolderPath` exists; consumers should validate their deployment paths. +- `TraceEvents` can log sensitive operational details. Keep it off unless debugging. + +## Package contents + +- `lib/net10.0/Squad.Agents.AI.dll` +- `lib/net10.0/Squad.Agents.AI.xml` for IntelliSense / API docs +- `README.md` for NuGet.org rendering +- `.nuspec` metadata with authors, tags, repository, and readme pointer +- `LICENSE` copied from the repository root + +## Sample + +A runnable console application is included at +`src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/`. It demonstrates the four +core integration patterns in one place: basic DI, keyed DI with multiple agents, +the `ConfigureCopilotClient` BYOK delegate, and streaming via `RunStreamingAsync`. + +### Prerequisites + +| Prerequisite | Notes | +|---|---| +| [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) | `dotnet --version` should print `10.x.x` | +| GitHub Copilot CLI on `PATH` | Install from [github.com/github/copilot-cli](https://github.com/github/copilot-cli); verify with `copilot --version` | +| Squad CLI installed | Follow the install guide in the [Squad repository README](https://github.com/bradygaster/squad#readme) | +| Initialized Squad team root | Run `squad init` in a directory; this becomes your team root | +| GitHub Copilot authentication | Run `gh auth login` or `copilot auth login` once before running the sample | + +Set the team root path before running: + +```bash +# Linux / macOS +export SQUAD_TEAM_ROOT=/path/to/your/team-root + +# Windows PowerShell +$env:SQUAD_TEAM_ROOT = "C:\path\to\your\team-root" +``` + +### Run + +Run all four flows in sequence: + +```bash +dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ +``` + +Run a single flow (1–4): + +```bash +dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ -- --flow=1 +dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ -- --flow=2 +dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ -- --flow=3 +dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ -- --flow=4 +``` + +### Flow walkthrough + +**Flow 1 — Basic DI registration** — `AddSquadAgent` registers `SquadAgent` and the +base `AIAgent` interface. Resolves the agent from DI, creates a session, calls +`RunAsync`, and prints `AgentResponse.Text`. + +**Flow 2 — Keyed DI (multiple agents)** — `AddKeyedSquadAgent` registers two agents +under keys `"alpha"` and `"beta"`. Resolution uses +`GetRequiredKeyedService("alpha")`. + +**Flow 3 — BYOK / `ConfigureCopilotClient` delegate** — the delegate receives +`CopilotClientOptions` after Squad has applied its defaults. Inject a custom token +or environment variable. The routing gate prevents accidental redirection of +`Cwd`, `CliPath`, or `CliArgs` — configure those on `SquadAgentOptions` directly. + +**Flow 4 — Streaming** — `RunStreamingAsync` returns +`IAsyncEnumerable`. Each `update.Text` fragment is written to +`Console.Write` without a newline for live token-by-token output. + +### Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `GitHub Copilot CLI was not found on PATH` | `copilot` binary is missing or not on `PATH` | Install from [github.com/github/copilot-cli](https://github.com/github/copilot-cli); verify with `copilot --version` | +| `Authentication failed` / `401` | Copilot CLI is not signed in | Run `gh auth login` or `copilot auth login` | +| `SquadFolderPath does not exist` | `SQUAD_TEAM_ROOT` points to a non-existent path | Set `SQUAD_TEAM_ROOT` to an initialized Squad team directory | +| `The system cannot find the file specified` (Win32Exception) | Copilot CLI not found | Same as first row above | +| Build error: `Package Squad.Agents.AI not found` | Sample uses a project reference; ensure you run from the repo root | Run `dotnet build` from the repository root or pass `--project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/` | + +## See also + +- Root repo: +- Microsoft Agent Framework: +- GitHub Copilot SDK authentication: +- GitHub Copilot CLI: +- Changelog: + +License: MIT. diff --git a/src/Squad.Agents.AI/Squad.Agents.AI.csproj b/src/Squad.Agents.AI/Squad.Agents.AI.csproj new file mode 100644 index 000000000..0aae5bd11 --- /dev/null +++ b/src/Squad.Agents.AI/Squad.Agents.AI.csproj @@ -0,0 +1,37 @@ + + + net8.0;net9.0;net10.0 + enable + enable + latest + true + Squad.Agents.AI + Squad Agents AI + Community SquadAgent for Microsoft Agent Framework. Exposes the Squad multi-agent CLI as an AIAgent that can be composed into any MAF app or workflow. + Tamir Dresher + squad;agents;ai;copilot;maf;multi-agent + https://github.com/bradygaster/squad + https://github.com/bradygaster/squad.git + git + main + MIT + README.md + true + 0.1.0-preview + + + + + + + + + + + + + + + + + diff --git a/src/Squad.Agents.AI/SquadAgent.cs b/src/Squad.Agents.AI/SquadAgent.cs new file mode 100644 index 000000000..b88c1c551 --- /dev/null +++ b/src/Squad.Agents.AI/SquadAgent.cs @@ -0,0 +1,250 @@ +using GitHub.Copilot.SDK; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Squad.Agents.AI; + +/// +/// Microsoft Agent Framework agent that delegates to a GitHub Copilot SDK client configured for a Squad team root. +/// Extends — Core* pass-through overrides are provided by the base class. +/// +/// +/// +/// Use +/// for DI registration in applications. Direct construction is useful for tests or simple console hosts. +/// +/// +/// +/// var agent = new SquadAgent(@"C:\repo"); +/// var response = await agent.RunAsync("Summarize the team."); +/// +/// +/// +public sealed class SquadAgent : DelegatingAIAgent, IAsyncDisposable +{ + private readonly CopilotClient _copilotClient; + private readonly ILogger? _logger; + private readonly bool _ownsClient; + private readonly SquadAgentOptions _options; + + // State-bag to thread pre-base-ctor state through chain constructors. + // DelegatingAIAgent requires the inner AIAgent at base() call time, so we + // build everything via static factory methods before invoking base(). + private readonly record struct SquadAgentState( + AIAgent Inner, + CopilotClient CopilotClient, + ILogger? Logger, + bool OwnsClient, + SquadAgentOptions Options); + + /// + /// Initializes a new with the Squad team root as the primary required parameter. + /// + /// Path to the initialized Squad team root. Required. + /// + /// Optional options. When a default is used. + /// is overwritten by + /// so there is exactly one source of truth for the team root. + /// + /// Optional logger factory; inject via DI or supply directly. + public SquadAgent(string squadFolderPath, SquadAgentOptions? options = null, ILoggerFactory? loggerFactory = null) + : this(BuildState(squadFolderPath, options, loggerFactory)) + { + } + + /// + /// Initializes a new from the options pattern (DI-friendly). + /// + /// + /// Options wrapper. must be set via a configure callback + /// or connection string before the agent is resolved from DI. + /// + /// Optional logger factory. + /// + /// Thrown when is or whitespace. + /// + public SquadAgent(IOptions options, ILoggerFactory? loggerFactory = null) + : this(BuildStateFromOptions(options, loggerFactory)) + { + } + + private SquadAgent(SquadAgentState state) + : base(state.Inner) + { + _copilotClient = state.CopilotClient; + _logger = state.Logger; + _ownsClient = state.OwnsClient; + _options = state.Options; + _logger?.LogInformation("SquadAgent initialized with name '{AgentName}', team root '{TeamRoot}'", + state.Options.AgentName, state.Options.SquadFolderPath); + } + + private static SquadAgentState BuildState(string squadFolderPath, SquadAgentOptions? options, ILoggerFactory? lf) + { + ArgumentException.ThrowIfNullOrWhiteSpace(squadFolderPath); + var resolved = options ?? new SquadAgentOptions(); + resolved.SquadFolderPath = squadFolderPath; // ctor param wins + return BuildStateInternal(resolved, lf); + } + + private static SquadAgentState BuildStateFromOptions(IOptions options, ILoggerFactory? lf) + { + ArgumentNullException.ThrowIfNull(options); + var opts = options.Value ?? throw new ArgumentException("Options.Value cannot be null.", nameof(options)); + if (string.IsNullOrWhiteSpace(opts.SquadFolderPath)) + throw new ArgumentException( + "SquadAgentOptions.SquadFolderPath must be set (via configure callback or connection string).", + nameof(options)); + return BuildStateInternal(opts, lf); + } + + private static SquadAgentState BuildStateInternal(SquadAgentOptions options, ILoggerFactory? lf) + { + var client = CreateCopilotClient(options, lf); + var inner = client.AsAIAgent(instructions: options.Instructions, name: options.AgentName ?? "Squad"); + return new SquadAgentState(inner, client, lf?.CreateLogger(), true, options); + } + + // ── Extensibility seam ────────────────────────────────────────────── + // CreateCopilotClient is the single factory method that translates + // SquadAgentOptions into a live CopilotClient. Three extension points + // feed into it: + // 1. GitHubTokenProvider / GitHubToken — credential override. + // 2. ConfigureCopilotClient — BYOK delegate for advanced SDK tuning. + // 3. Connection-string binding (handled before this method by the + // SquadAgentOptionsConfigurator). + // Any future credential-store or model-property hooks should integrate + // through SquadAgentOptions and be applied inside this method. + // ──────────────────────────────────────────────────────────────────── + private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILoggerFactory? loggerFactory) + { + var logger = loggerFactory?.CreateLogger(); + + // Resolve token: provider takes precedence over direct property + string? resolvedToken = null; + if (options.GitHubTokenProvider is not null) + { + // Call provider synchronously (constructor context requires sync) + resolvedToken = options.GitHubTokenProvider(CancellationToken.None).GetAwaiter().GetResult(); + } + else + { + resolvedToken = options.GitHubToken; + } + + var clientOptions = new CopilotClientOptions + { + CliPath = options.CliPath, + Cwd = options.Cwd ?? options.SquadFolderPath, + GitHubToken = resolvedToken + }; + + // Preserve CLI args parsed from connection strings or supplied by user code for the SDK's CLI invocation. + if (options.CliArgs.Count > 0) + { + clientOptions.CliArgs = options.CliArgs.ToArray(); + } + + // Copy environment variables + if (options.Environment.Count > 0) + { + var envDict = new Dictionary(); + foreach (var kvp in options.Environment) + { + if (kvp.Value != null) + envDict[kvp.Key] = kvp.Value; + } + clientOptions.Environment = envDict; + } + + // Set logger if trace events enabled + if (options.TraceEvents && loggerFactory != null) + { + clientOptions.Logger = loggerFactory.CreateLogger(); + } + + // ── BYOK / ConfigureCopilotClient delegate ───────────────────── + // Allow consumers to customize the CopilotClientOptions after Squad + // applies its standard values. After the delegate runs, snapshot and + // restore routing-critical properties to prevent accidental or + // malicious changes that would route the agent to a different CLI + // process. (Picard Condition 1: hard routing gate.) + if (options.ConfigureCopilotClient is not null) + { + // Snapshot routing properties before the delegate runs + var snapshotCwd = clientOptions.Cwd; + var snapshotCliPath = clientOptions.CliPath; + // snapshot is a clone — in-place CliArgs mutation by SDK consumers is also caught + var cliArgsSnapshot = clientOptions.CliArgs?.ToArray(); + + options.ConfigureCopilotClient(clientOptions); + + // Restore routing properties if changed (SC-3: post-delegate warning) + bool restored = false; + if (!string.Equals(clientOptions.Cwd, snapshotCwd, StringComparison.Ordinal)) + { + logger?.LogWarning( + "ConfigureCopilotClient delegate changed Cwd from '{Original}' to '{Changed}'; " + + "restoring original value to preserve Squad routing.", + snapshotCwd, clientOptions.Cwd); + clientOptions.Cwd = snapshotCwd; + restored = true; + } + + if (!string.Equals(clientOptions.CliPath, snapshotCliPath, StringComparison.Ordinal)) + { + logger?.LogWarning( + "ConfigureCopilotClient delegate changed CliPath from '{Original}' to '{Changed}'; " + + "restoring original value to preserve Squad routing.", + snapshotCliPath, clientOptions.CliPath); + clientOptions.CliPath = snapshotCliPath; + restored = true; + } + + if (!(clientOptions.CliArgs ?? Array.Empty()).SequenceEqual(cliArgsSnapshot ?? Array.Empty())) + { + logger?.LogWarning( + "ConfigureCopilotClient delegate changed CliArgs; " + + "restoring original value to preserve Squad routing."); + clientOptions.CliArgs = cliArgsSnapshot; + restored = true; + } + + if (restored) + { + logger?.LogWarning( + "One or more routing properties were restored after ConfigureCopilotClient delegate ran. " + + "To set Cwd, CliPath, or CliArgs, configure them on SquadAgentOptions instead."); + } + } + + return new CopilotClient(clientOptions); + } + + /// + /// Gets the display name exposed by this agent. + /// Prefers over the inner agent's name. + /// + public override string? Name => _options.AgentName ?? base.Name; + + /// + /// Gets the agent description, falling back to a Squad-specific default when the inner agent provides none. + /// + public override string? Description => base.Description ?? "Squad multi-agent CLI participant"; + + /// + /// Disposes the owned Copilot client and disposable inner agent resources. + /// + /// A value task that completes when disposal finishes. + public async ValueTask DisposeAsync() + { + if (_ownsClient) + await _copilotClient.DisposeAsync().ConfigureAwait(false); + + if (InnerAgent is IAsyncDisposable innerDisposable) + await innerDisposable.DisposeAsync().ConfigureAwait(false); + + _logger?.LogDebug("SquadAgent disposed"); + } +} diff --git a/src/Squad.Agents.AI/SquadAgentOptions.cs b/src/Squad.Agents.AI/SquadAgentOptions.cs new file mode 100644 index 000000000..5dcd996ac --- /dev/null +++ b/src/Squad.Agents.AI/SquadAgentOptions.cs @@ -0,0 +1,152 @@ +using System.Text.Json.Serialization; +using GitHub.Copilot.SDK; + +namespace Squad.Agents.AI; + +/// +/// Configures construction, connection-string binding, and Copilot CLI process settings. +/// +/// +/// +/// var options = new SquadAgentOptions +/// { +/// SquadFolderPath = @"C:\repo", +/// }; +/// options.CliArgs.Add("--yolo"); +/// +/// +public sealed class SquadAgentOptions +{ + /// + /// Gets or sets the initialized Squad team root. + /// + public string? SquadFolderPath { get; set; } + + /// + /// Gets or sets an explicit Copilot CLI executable path. When unset, the SDK resolves the default CLI from `PATH`. + /// + public string? CliPath { get; set; } + + /// + /// Gets additional CLI arguments appended when the Copilot CLI process is started. + /// + public IList CliArgs { get; } = new List(); + + /// + /// Gets or sets the CLI process working directory. When unset, is used. + /// + public string? Cwd { get; set; } + + /// + /// Gets additional environment variables injected into the CLI process. + /// + /// + /// + /// This dictionary is excluded from JSON serialization to prevent credential leakage. + /// Values for keys matching token/secret patterns are redacted in . + /// + /// Avoid placing user-controlled values in this dictionary. + /// + [JsonIgnore] + public IDictionary Environment { get; } = new Dictionary(); + + /// + /// Gets or sets a direct GitHub token for advanced host-controlled authentication scenarios. + /// + /// + /// The value is ignored by JSON serialization and redacted by , but still lives in the options object. + /// Prefer leaving this unset for local signed-in user authentication; use for production hosts that retrieve tokens dynamically. + /// + [JsonIgnore] + public string? GitHubToken { get; set; } + + /// + /// Gets or sets the callback that provides a GitHub token during agent construction. + /// + /// + /// When set, this provider takes precedence over . + /// + [JsonIgnore] + public Func>? GitHubTokenProvider { get; set; } + + /// + /// Gets or sets whether verbose Copilot SDK logging is enabled. + /// + /// + /// The DI registration emits a startup warning whenever this is enabled because traces may contain sensitive details. + /// + public bool TraceEvents { get; set; } = false; + + /// + /// Gets or sets the display name for the resulting . + /// + public string AgentName { get; set; } = "Squad"; + + /// + /// Gets or sets optional system instructions passed to the inner Copilot-backed agent. + /// + public string? Instructions { get; set; } + + /// + /// Gets or sets a delegate that customizes the underlying after Squad + /// applies its standard values. Use this for advanced scenarios such as injecting custom environment + /// variables, setting timeouts, or providing a custom logger to the Copilot SDK. + /// + /// + /// Security note: Do not override Cwd, CliPath, or CliArgs through this + /// delegate — Squad enforces routing invariants and will restore its own values if they are changed. + /// A warning is logged when this occurs. + /// The delegate has full read/write access to , including + /// GitHubToken. If the delegate replaces the token, the replacement takes effect. + /// Document this as the final authority on token value. + /// + /// + /// + /// options.ConfigureCopilotClient = clientOpts => + /// { + /// clientOpts.Environment["MY_CUSTOM_VAR"] = "custom-value"; + /// }; + /// + /// + [JsonIgnore] + public Action? ConfigureCopilotClient { get; set; } + + private static readonly string[] TokenPatterns = { "TOKEN", "KEY", "SECRET", "HMAC", "PASSWORD", "CREDENTIAL" }; + + private static bool IsTokenKey(string key) + { + var upper = key.ToUpperInvariant(); + foreach (var pattern in TokenPatterns) + { + if (upper.Contains(pattern)) + return true; + } + return false; + } + + /// + /// Returns a diagnostic representation with sensitive values redacted. + /// + /// + /// GitHubToken is always shown as [REDACTED]. + /// Environment values whose keys match token/secret patterns + /// (*TOKEN*, *KEY*, *SECRET*, *HMAC*, *PASSWORD*, *CREDENTIAL*) + /// are shown as [REDACTED]; other environment entries show their value. + /// + /// A string containing non-secret option values. + public override string ToString() + { + var envDisplay = "{}"; + if (Environment.Count > 0) + { + var entries = Environment.Select(kvp => + IsTokenKey(kvp.Key) + ? $"{kvp.Key}=[REDACTED]" + : $"{kvp.Key}={kvp.Value}"); + envDisplay = $"{{ {string.Join(", ", entries)} }}"; + } + + return $"SquadAgentOptions {{ SquadFolderPath = {SquadFolderPath}, AgentName = {AgentName}, " + + $"GitHubToken = [REDACTED], Environment = {envDisplay}, TraceEvents = {TraceEvents} }}"; + } +} diff --git a/src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs b/src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs new file mode 100644 index 000000000..4edff601d --- /dev/null +++ b/src/Squad.Agents.AI/SquadAgentOptionsConfigurator.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Squad.Agents.AI; + +/// +/// Binds SquadAgentOptions from a configured connection string. +/// Runs before user-supplied configure lambda; user settings always win. +/// +internal sealed class SquadAgentOptionsConfigurator : IConfigureNamedOptions +{ + private readonly IConfiguration _configuration; + private readonly string _optionsName; + private readonly string _connectionStringName; + + public SquadAgentOptionsConfigurator(IConfiguration configuration) + : this(configuration, Options.DefaultName, SquadServiceCollectionExtensions.DefaultConnectionStringName) + { + } + + internal SquadAgentOptionsConfigurator( + IConfiguration configuration, + string optionsName, + string connectionStringName) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _optionsName = optionsName ?? Options.DefaultName; + _connectionStringName = connectionStringName ?? throw new ArgumentNullException(nameof(connectionStringName)); + } + + public void Configure(SquadAgentOptions options) => Configure(Options.DefaultName, options); + + public void Configure(string? name, SquadAgentOptions options) + { + if (!string.Equals(name ?? Options.DefaultName, _optionsName, StringComparison.Ordinal)) + return; + + var connectionString = _configuration.GetConnectionString(_connectionStringName); + + if (string.IsNullOrWhiteSpace(connectionString)) + return; + + var parsedOptions = SquadConnectionFactory.FromConnectionString(connectionString); + + // Only apply parsed values if user hasn't set them explicitly + // User callback runs AFTER this configurator, so any null/empty here will be overridden + if (string.IsNullOrWhiteSpace(options.SquadFolderPath)) + options.SquadFolderPath = parsedOptions.SquadFolderPath; + + if (string.IsNullOrWhiteSpace(options.CliPath)) + options.CliPath = parsedOptions.CliPath; + + if (string.IsNullOrWhiteSpace(options.Cwd)) + options.Cwd = parsedOptions.Cwd; + + // Merge CLI args (append to existing list) + foreach (var arg in parsedOptions.CliArgs) + { + if (!options.CliArgs.Contains(arg)) + options.CliArgs.Add(arg); + } + + // Merge environment variables (don't overwrite existing) + foreach (var kvp in parsedOptions.Environment) + { + if (!options.Environment.ContainsKey(kvp.Key)) + options.Environment[kvp.Key] = kvp.Value; + } + } +} diff --git a/src/Squad.Agents.AI/SquadConnectionFactory.cs b/src/Squad.Agents.AI/SquadConnectionFactory.cs new file mode 100644 index 000000000..896d5ad15 --- /dev/null +++ b/src/Squad.Agents.AI/SquadConnectionFactory.cs @@ -0,0 +1,102 @@ +namespace Squad.Agents.AI; + +/// +/// Parses Squad connection strings into instances. +/// +public static class SquadConnectionFactory +{ + /// + /// Parses a Squad connection string into agent options. + /// + /// PATH form or `squad://` URI form connection string. + /// Options populated from the supplied connection string. + /// Thrown when is null, empty, or whitespace. + /// + /// PATH form maps the entire value to and . + /// URI form parses `teamRoot`, `cliPath`, `cwd`, `cliArgs`, and `env` query values; the URI host is reserved. + /// + /// + /// + /// var options = SquadConnectionFactory.FromConnectionString(@"C:\repo"); + /// var advanced = SquadConnectionFactory.FromConnectionString( + /// "squad://localhost?teamRoot=C%3A%5Crepo&cliArgs=--yolo"); + /// + /// + public static SquadAgentOptions FromConnectionString(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); + } + + var options = new SquadAgentOptions(); + + if (connectionString.StartsWith("squad://", StringComparison.OrdinalIgnoreCase)) + { + // URI form + var uri = new Uri(connectionString); + var query = ParseQueryString(uri.Query); + + if (query.TryGetValue("teamRoot", out var teamRoot)) + options.SquadFolderPath = teamRoot; + + if (query.TryGetValue("cliPath", out var cliPath)) + options.CliPath = cliPath; + + if (query.TryGetValue("cwd", out var cwd)) + options.Cwd = cwd; + + if (query.TryGetValue("cliArgs", out var cliArgsString)) + { + foreach (var arg in cliArgsString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + options.CliArgs.Add(arg); + } + } + + // Parse environment variables if present (format: key1=val1;key2=val2) + if (query.TryGetValue("env", out var envString)) + { + foreach (var pair in envString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = pair.Split('=', 2); + if (parts.Length == 2) + { + options.Environment[parts[0]] = parts[1]; + } + } + } + } + else + { + // PATH form — treat entire string as team root path + options.SquadFolderPath = connectionString; + options.Cwd = connectionString; + } + + return options; + } + + private static Dictionary ParseQueryString(string query) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrEmpty(query)) + return result; + + // Remove leading '?' if present + if (query.StartsWith('?')) + query = query.Substring(1); + + foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = pair.Split('=', 2); + if (parts.Length == 2) + { + result[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]); + } + } + + return result; + } +} diff --git a/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs new file mode 100644 index 000000000..045c7b30b --- /dev/null +++ b/src/Squad.Agents.AI/SquadServiceCollectionExtensions.cs @@ -0,0 +1,284 @@ +using Microsoft.Agents.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Squad.Agents.AI; + +/// +/// Extension methods for registering in dependency injection. +/// +public static class SquadServiceCollectionExtensions +{ + internal const string DefaultConnectionStringName = "squad"; + + /// + /// Registers and base with scoped lifetime. + /// + /// Service collection to update. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + /// + /// + /// builder.Services.AddSquadAgent(o => o.SquadFolderPath = @"C:\repo"); + /// + /// + public static IServiceCollection AddSquadAgent( + this IServiceCollection services, + Action? configure = null) + => AddSquadAgentCore(services, name: null, ServiceLifetime.Scoped, configure); + + /// + /// Registers and base with scoped lifetime using a named connection string. + /// + /// Service collection to update. + /// Logical Squad name. For example, "research" reads ConnectionStrings:squad-research. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddSquadAgent( + this IServiceCollection services, + string name, + Action? configure = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return AddSquadAgentCore(services, name, ServiceLifetime.Scoped, configure); + } + + /// + /// Registers and base with the specified lifetime. + /// + /// Service collection to update. + /// Lifetime used for the concrete and base agent registrations. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddSquadAgent( + this IServiceCollection services, + ServiceLifetime lifetime, + Action? configure = null) + => AddSquadAgentCore(services, name: null, lifetime, configure); + + /// + /// Registers and base with the specified lifetime using a named connection string. + /// + /// Service collection to update. + /// Logical Squad name. For example, "research" reads ConnectionStrings:squad-research. + /// Lifetime used for the concrete and base agent registrations. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddSquadAgent( + this IServiceCollection services, + string name, + ServiceLifetime lifetime, + Action? configure = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return AddSquadAgentCore(services, name, lifetime, configure); + } + + // ── Keyed DI overloads ─────────────────────────────────────────────────── + + /// + /// Registers as a keyed service with scoped lifetime. Resolve via + /// [FromKeyedServices("myKey")] SquadAgent agent or + /// provider.GetRequiredKeyedService<SquadAgent>("myKey"). + /// + /// Service collection to update. + /// The DI service key for keyed resolution. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + /// + /// + /// builder.Services.AddKeyedSquadAgent("research", o => o.SquadFolderPath = @"C:\research-team"); + /// builder.Services.AddKeyedSquadAgent("platform", o => o.SquadFolderPath = @"C:\platform-team"); + /// + /// // Resolve in a controller or minimal API: + /// app.MapGet("/ask", ([FromKeyedServices("research")] SquadAgent agent) => ...); + /// + /// + public static IServiceCollection AddKeyedSquadAgent( + this IServiceCollection services, + string serviceKey, + Action? configure = null) + => AddKeyedSquadAgentCore(services, serviceKey, name: null, ServiceLifetime.Scoped, configure); + + /// + /// Registers as a keyed service with scoped lifetime using a named connection string. + /// + /// Service collection to update. + /// The DI service key for keyed resolution. + /// Logical Squad name for connection-string lookup. For example, "research" reads ConnectionStrings:squad-research. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddKeyedSquadAgent( + this IServiceCollection services, + string serviceKey, + string name, + Action? configure = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return AddKeyedSquadAgentCore(services, serviceKey, name, ServiceLifetime.Scoped, configure); + } + + /// + /// Registers as a keyed service with the specified lifetime. + /// + /// Service collection to update. + /// The DI service key for keyed resolution. + /// Lifetime used for the keyed agent registration. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddKeyedSquadAgent( + this IServiceCollection services, + string serviceKey, + ServiceLifetime lifetime, + Action? configure = null) + => AddKeyedSquadAgentCore(services, serviceKey, name: null, lifetime, configure); + + /// + /// Registers as a keyed service with the specified lifetime using a named connection string. + /// + /// Service collection to update. + /// The DI service key for keyed resolution. + /// Logical Squad name for connection-string lookup. + /// Lifetime used for the keyed agent registration. + /// Optional callback that customizes after connection-string binding. + /// The same instance for chaining. + public static IServiceCollection AddKeyedSquadAgent( + this IServiceCollection services, + string serviceKey, + string name, + ServiceLifetime lifetime, + Action? configure = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return AddKeyedSquadAgentCore(services, serviceKey, name, lifetime, configure); + } + + // ── Core implementation (non-keyed) ────────────────────────────────────── + + private static IServiceCollection AddSquadAgentCore( + IServiceCollection services, + string? name, + ServiceLifetime lifetime, + Action? configure) + { + ArgumentNullException.ThrowIfNull(services); + + var optionsName = GetOptionsName(name); + var connectionStringName = GetConnectionStringName(name); + + RegisterOptionsInfrastructure(services, optionsName, connectionStringName, configure); + + // Register SquadAgent with specified lifetime + services.Add(new ServiceDescriptor( + typeof(SquadAgent), + sp => + { + var options = sp.GetRequiredService>().Get(optionsName); + var loggerFactory = sp.GetService(); + var folderPath = options.SquadFolderPath; + if (string.IsNullOrWhiteSpace(folderPath)) + throw new InvalidOperationException( + "SquadAgentOptions.SquadFolderPath must be configured (via configure callback or connection string) before resolving SquadAgent."); + return new SquadAgent(folderPath, options, loggerFactory); + }, + lifetime)); + + // AIAgent base registration — so consumers can resolve via IEnumerable + services.Add(new ServiceDescriptor( + typeof(AIAgent), + sp => sp.GetRequiredService(), + lifetime)); + + return services; + } + + // ── Core implementation (keyed) ────────────────────────────────────────── + + private static IServiceCollection AddKeyedSquadAgentCore( + IServiceCollection services, + string serviceKey, + string? name, + ServiceLifetime lifetime, + Action? configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(serviceKey); + + // Use the serviceKey as the options name when no explicit name is given + var optionsName = GetOptionsName(name ?? serviceKey); + var connectionStringName = GetConnectionStringName(name ?? serviceKey); + + RegisterOptionsInfrastructure(services, optionsName, connectionStringName, configure); + + // Register keyed SquadAgent + services.Add(new ServiceDescriptor( + typeof(SquadAgent), + serviceKey, + (sp, _) => + { + var options = sp.GetRequiredService>().Get(optionsName); + var loggerFactory = sp.GetService(); + var folderPath = options.SquadFolderPath; + if (string.IsNullOrWhiteSpace(folderPath)) + throw new InvalidOperationException( + "SquadAgentOptions.SquadFolderPath must be configured (via configure callback or connection string) before resolving SquadAgent."); + return new SquadAgent(folderPath, options, loggerFactory); + }, + lifetime)); + + // Keyed AIAgent — forwards to keyed SquadAgent + services.Add(new ServiceDescriptor( + typeof(AIAgent), + serviceKey, + (sp, _) => sp.GetRequiredKeyedService(serviceKey), + lifetime)); + + return services; + } + + // ── Shared options registration ────────────────────────────────────────── + + private static void RegisterOptionsInfrastructure( + IServiceCollection services, + string optionsName, + string connectionStringName, + Action? configure) + { + // Register connection string configurator FIRST (runs before user callback) + services.AddSingleton>(sp => + new SquadAgentOptionsConfigurator( + sp.GetRequiredService(), + optionsName, + connectionStringName)); + + if (configure is not null) + { + services.Configure(optionsName, configure); + } + + // TraceEvents=true → warn because verbose traces can contain sensitive details + services.AddOptions(optionsName) + .PostConfigure((opts, loggerFactory) => + { + if (opts.TraceEvents) + { + var logger = loggerFactory.CreateLogger("Squad.Agents.AI.Startup"); + logger.LogWarning( + "SquadAgentOptions.TraceEvents=true. Verbose tracing is enabled; " + + "not recommended for production use."); + } + }); + } + + private static string GetOptionsName(string? name) + { + return string.IsNullOrWhiteSpace(name) ? Options.DefaultName : name; + } + + private static string GetConnectionStringName(string? name) + { + return string.IsNullOrWhiteSpace(name) ? DefaultConnectionStringName : $"{DefaultConnectionStringName}-{name}"; + } +} diff --git a/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs new file mode 100644 index 000000000..9b6c3824c --- /dev/null +++ b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Program.cs @@ -0,0 +1,337 @@ +// ============================================================ +// Squad.Agents.AI — Sample application +// +// Demonstrates four distinct integration patterns: +// Flow 1 — Basic DI registration and a single prompt +// Flow 2 — Keyed DI with two agents under different service keys +// Flow 3 — BYOK / ConfigureCopilotClient delegate +// Flow 4 — Streaming via RunStreamingAsync +// +// Run: dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ +// Run one flow: dotnet run --project src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/ -- --flow=1 +// ============================================================ + +using Microsoft.Agents.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Squad.Agents.AI; + +// ── Argument parsing ───────────────────────────────────────────────────────── +int? selectedFlow = null; +foreach (var arg in args) +{ + if (arg.StartsWith("--flow=", StringComparison.OrdinalIgnoreCase) && + int.TryParse(arg["--flow=".Length..], out var n) && + n is >= 1 and <= 4) + { + selectedFlow = n; + } +} + +bool RunFlow(int flow) => selectedFlow is null || selectedFlow == flow; + +// Squad team root: read from env var or fall back to current directory. +// Real-world usage: set SQUAD_TEAM_ROOT to the path of an initialized Squad team. +var teamRoot = System.Environment.GetEnvironmentVariable("SQUAD_TEAM_ROOT") + ?? System.IO.Directory.GetCurrentDirectory(); + +PrintBanner($"Squad.Agents.AI v0.1 — sample run (team root: {teamRoot})"); +Console.WriteLine(); + +// ───────────────────────────────────────────────────────────────────────────── +// Flow 1: Basic DI registration — simplest path to running an agent +// ───────────────────────────────────────────────────────────────────────────── +if (RunFlow(1)) +{ + PrintHeader("Flow 1 — Basic DI registration"); + + var host1 = Host.CreateApplicationBuilder(args); + host1.Logging.SetMinimumLevel(LogLevel.Warning); // keep sample output clean + host1.Services.AddSquadAgent(o => + { + o.SquadFolderPath = teamRoot; + o.AgentName = "SampleSquad"; + o.Instructions = "You are a helpful assistant. Respond concisely."; + }); + + using var app1 = host1.Build(); + + var agent1 = app1.Services.GetRequiredService(); + + Console.WriteLine($"Agent name : {agent1.Name}"); + Console.WriteLine("Sending : \"What is 2 + 2?\""); + + var result1 = await RunWithErrorHandlingAsync(async () => + { + var session1 = await agent1.CreateSessionAsync(); + return await agent1.RunAsync("What is 2 + 2?", session1); + }); + + if (result1 is not null) + Console.WriteLine($"Response : {result1.Text}"); + + await DisposeIfNeeded(agent1); + PrintDone("Flow 1"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flow 2: Keyed DI — two agents registered under distinct service keys +// ───────────────────────────────────────────────────────────────────────────── +if (RunFlow(2)) +{ + PrintHeader("Flow 2 — Keyed DI (two agents)"); + + var host2 = Host.CreateApplicationBuilder(args); + host2.Logging.SetMinimumLevel(LogLevel.Warning); + + // "alpha" agent — focuses on concise answers + host2.Services.AddKeyedSquadAgent("alpha", o => + { + o.SquadFolderPath = teamRoot; + o.AgentName = "AlphaSquad"; + o.Instructions = "Be concise. Answer in one sentence."; + }); + + // "beta" agent — verbose mode + host2.Services.AddKeyedSquadAgent("beta", o => + { + o.SquadFolderPath = teamRoot; + o.AgentName = "BetaSquad"; + o.Instructions = "Provide a detailed explanation with examples."; + }); + + using var app2 = host2.Build(); + + var alphaAgent = app2.Services.GetRequiredKeyedService("alpha"); + var betaAgent = app2.Services.GetRequiredKeyedService("beta"); + + Console.WriteLine($"[alpha] name: {alphaAgent.Name}"); + Console.WriteLine($"[beta] name: {betaAgent.Name}"); + Console.WriteLine(); + + var alphaResult = await RunWithErrorHandlingAsync(async () => + { + var s = await alphaAgent.CreateSessionAsync(); + return await alphaAgent.RunAsync("Explain the concept of dependency injection.", s); + }); + if (alphaResult is not null) + Console.WriteLine($"[alpha] {alphaResult.Text}"); + + Console.WriteLine(); + + var betaResult = await RunWithErrorHandlingAsync(async () => + { + var s = await betaAgent.CreateSessionAsync(); + return await betaAgent.RunAsync("Explain the concept of dependency injection.", s); + }); + if (betaResult is not null) + Console.WriteLine($"[beta] {betaResult.Text}"); + + await DisposeIfNeeded(alphaAgent); + await DisposeIfNeeded(betaAgent); + PrintDone("Flow 2"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flow 3: BYOK / ConfigureCopilotClient delegate +// +// ConfigureCopilotClient lets you post-process CopilotClientOptions after +// Squad applies its standard values. The routing gate ensures Cwd/CliPath/ +// CliArgs cannot be replaced by accident; everything else is fair game. +// ───────────────────────────────────────────────────────────────────────────── +if (RunFlow(3)) +{ + PrintHeader("Flow 3 — BYOK / ConfigureCopilotClient delegate"); + + // Simulate a token from a credential store (never hardcode real tokens). + // In production replace this with Key Vault, managed identity, etc. + const string simulatedToken = "YOUR_GITHUB_PAT_HERE"; + + var host3 = Host.CreateApplicationBuilder(args); + host3.Logging.SetMinimumLevel(LogLevel.Warning); + host3.Services.AddSquadAgent(o => + { + o.SquadFolderPath = teamRoot; + o.AgentName = "BYOKSquad"; + + // ConfigureCopilotClient is the BYOK extension point. + // It runs after Squad populates its own values; use it to inject a + // custom token or environment variable from your own credential store. + o.ConfigureCopilotClient = clientOpts => + { + // Inject a token from an external credential store + clientOpts.GitHubToken = simulatedToken; + + // Inject custom process-level variables by assigning a new dictionary. + // IReadOnlyDictionary indexer is read-only; always assign a new instance. + clientOpts.Environment = new Dictionary + { + ["SQUAD_SAMPLE_VAR"] = "demo-value" + }; + }; + }); + + using var app3 = host3.Build(); + + var agent3 = app3.Services.GetRequiredService(); + Console.WriteLine($"Agent name : {agent3.Name}"); + Console.WriteLine("BYOK delegate registered — custom token + env var will be forwarded to Copilot SDK."); + Console.WriteLine("Sending : \"Ping\""); + + var result3 = await RunWithErrorHandlingAsync(async () => + { + var s = await agent3.CreateSessionAsync(); + return await agent3.RunAsync("Ping", s); + }); + if (result3 is not null) + Console.WriteLine($"Response : {result3.Text}"); + + await DisposeIfNeeded(agent3); + PrintDone("Flow 3"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flow 4: Streaming — print each response token as it arrives +// ───────────────────────────────────────────────────────────────────────────── +if (RunFlow(4)) +{ + PrintHeader("Flow 4 — Streaming (RunStreamingAsync)"); + + var host4 = Host.CreateApplicationBuilder(args); + host4.Logging.SetMinimumLevel(LogLevel.Warning); + host4.Services.AddSquadAgent(o => + { + o.SquadFolderPath = teamRoot; + o.AgentName = "StreamSquad"; + }); + + using var app4 = host4.Build(); + + var agent4 = app4.Services.GetRequiredService(); + Console.WriteLine($"Agent name : {agent4.Name}"); + Console.WriteLine("Streaming : \"Count to five slowly.\""); + Console.WriteLine("Tokens :"); + Console.Write(" "); + + var streamOk = await RunStreamingWithErrorHandlingAsync(async () => + { + var s = await agent4.CreateSessionAsync(); + await foreach (var update in agent4.RunStreamingAsync("Count to five slowly.", s)) + { + var text = update.Text; + if (!string.IsNullOrEmpty(text)) + Console.Write(text); + } + }); + + if (streamOk) + Console.WriteLine(); // newline after streaming tokens + + await DisposeIfNeeded(agent4); + PrintDone("Flow 4"); +} + +Console.WriteLine(); +PrintBanner("All requested flows completed."); + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +static void PrintBanner(string text) +{ + var line = new string('=', Math.Max(50, text.Length + 4)); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(line); + Console.WriteLine($" {text}"); + Console.WriteLine(line); + Console.ResetColor(); +} + +static void PrintHeader(string title) +{ + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"── {title} ──"); + Console.ResetColor(); +} + +static void PrintDone(string flow) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"[{flow} ✓]"); + Console.ResetColor(); +} + +static async Task RunWithErrorHandlingAsync(Func> action) +{ + try + { + return await action(); + } + catch (Exception ex) when (IsCliMissingException(ex)) + { + PrintCliError(); + return null; + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"[ERROR] {ex.GetType().Name}: {ex.Message}"); + Console.ResetColor(); + return null; + } +} + +static async Task RunStreamingWithErrorHandlingAsync(Func action) +{ + try + { + await action(); + return true; + } + catch (Exception ex) when (IsCliMissingException(ex)) + { + PrintCliError(); + return false; + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\n[ERROR] {ex.GetType().Name}: {ex.Message}"); + Console.ResetColor(); + return false; + } +} + +static bool IsCliMissingException(Exception ex) +{ + var msg = ex.Message; + return msg.Contains("copilot", StringComparison.OrdinalIgnoreCase) || + msg.Contains("not found", StringComparison.OrdinalIgnoreCase) || + msg.Contains("cannot find", StringComparison.OrdinalIgnoreCase) || + msg.Contains("No such file", StringComparison.OrdinalIgnoreCase) || + ex is System.ComponentModel.Win32Exception; +} + +static void PrintCliError() +{ + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(); + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ GitHub Copilot CLI was not found on PATH │"); + Console.WriteLine("│ │"); + Console.WriteLine("│ Install it and sign in before running this sample: │"); + Console.WriteLine("│ https://github.com/github/copilot-cli │"); + Console.WriteLine("│ │"); + Console.WriteLine("│ See the sample README.md for full prerequisites. │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘"); + Console.ResetColor(); +} + +static async ValueTask DisposeIfNeeded(SquadAgent agent) +{ + if (agent is IAsyncDisposable d) + await d.DisposeAsync(); +} diff --git a/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/README.md b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/README.md new file mode 100644 index 000000000..bcaea14e4 --- /dev/null +++ b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/README.md @@ -0,0 +1,3 @@ +# Squad.Agents.AI Sample + +For docs see [../../README.md#sample](../../README.md#sample). diff --git a/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Squad.Agents.AI.Sample.csproj b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Squad.Agents.AI.Sample.csproj new file mode 100644 index 000000000..13209c281 --- /dev/null +++ b/src/Squad.Agents.AI/samples/Squad.Agents.AI.Sample/Squad.Agents.AI.Sample.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + latest + Exe + false + + false + + + + + + + + + + diff --git a/test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj b/test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj new file mode 100644 index 000000000..cc129f0c0 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/Squad.Agents.AI.Tests.csproj @@ -0,0 +1,25 @@ + + + net8.0;net9.0;net10.0 + enable + enable + latest + false + true + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/test/Squad.Agents.AI.Tests/SquadAgentOptionsSecurityTests.cs b/test/Squad.Agents.AI.Tests/SquadAgentOptionsSecurityTests.cs new file mode 100644 index 000000000..724d454f1 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadAgentOptionsSecurityTests.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using Xunit; + +namespace Squad.Agents.AI.Tests; + +/// +/// Validates that redacts sensitive values +/// in ToString() and JSON serialization (Worf SC-1, SC-2, SC-7, SC-8). +/// +public class SquadAgentOptionsSecurityTests +{ + [Fact] + public void ToString_RedactsGitHubToken() + { + var options = new SquadAgentOptions + { + GitHubToken = "ghp_super_secret_token_12345" + }; + + var result = options.ToString(); + + Assert.DoesNotContain("ghp_super_secret_token", result); + Assert.Contains("GitHubToken = [REDACTED]", result); + } + + [Fact] + public void ToString_RedactsEnvironmentTokenKeys() + { + var options = new SquadAgentOptions(); + options.Environment["MY_API_TOKEN"] = "secret-token-value"; + options.Environment["AZURE_SECRET"] = "secret-azure-value"; + options.Environment["HMAC_KEY"] = "hmac-key-value"; + options.Environment["MY_PASSWORD"] = "password-value"; + options.Environment["SERVICE_CREDENTIAL"] = "credential-value"; + options.Environment["SAFE_SETTING"] = "visible-value"; + + var result = options.ToString(); + + Assert.DoesNotContain("secret-token-value", result); + Assert.DoesNotContain("secret-azure-value", result); + Assert.DoesNotContain("hmac-key-value", result); + Assert.DoesNotContain("password-value", result); + Assert.DoesNotContain("credential-value", result); + Assert.Contains("MY_API_TOKEN=[REDACTED]", result); + Assert.Contains("AZURE_SECRET=[REDACTED]", result); + Assert.Contains("HMAC_KEY=[REDACTED]", result); + Assert.Contains("MY_PASSWORD=[REDACTED]", result); + Assert.Contains("SERVICE_CREDENTIAL=[REDACTED]", result); + Assert.Contains("SAFE_SETTING=visible-value", result); + } + + [Fact] + public void ToString_ShowsNonSensitiveEnvironmentValues() + { + var options = new SquadAgentOptions(); + options.Environment["SQUAD_ROUTE"] = "enabled"; + options.Environment["DEBUG_LEVEL"] = "verbose"; + + var result = options.ToString(); + + Assert.Contains("SQUAD_ROUTE=enabled", result); + Assert.Contains("DEBUG_LEVEL=verbose", result); + } + + [Fact] + public void ToString_EmptyEnvironment_ShowsEmptyBraces() + { + var options = new SquadAgentOptions(); + + var result = options.ToString(); + + Assert.Contains("Environment = {}", result); + } + + [Fact] + public void JsonSerialize_ExcludesGitHubToken() + { + var options = new SquadAgentOptions + { + GitHubToken = "ghp_leaked_token" + }; + + var json = JsonSerializer.Serialize(options); + + Assert.DoesNotContain("ghp_leaked_token", json); + Assert.DoesNotContain("GitHubToken", json); + } + + [Fact] + public void JsonSerialize_ExcludesEnvironment() + { + var options = new SquadAgentOptions(); + options.Environment["MY_SECRET_KEY"] = "leaked-secret"; + options.Environment["SAFE_VALUE"] = "safe"; + + var json = JsonSerializer.Serialize(options); + + Assert.DoesNotContain("leaked-secret", json); + Assert.DoesNotContain("MY_SECRET_KEY", json); + Assert.DoesNotContain("Environment", json); + } + + [Fact] + public void JsonSerialize_ExcludesGitHubTokenProvider() + { + var options = new SquadAgentOptions + { + GitHubTokenProvider = _ => new ValueTask("provider-secret") + }; + + var json = JsonSerializer.Serialize(options); + + Assert.DoesNotContain("provider-secret", json); + Assert.DoesNotContain("GitHubTokenProvider", json); + } + + [Fact] + public void JsonSerialize_ExcludesConfigureCopilotClient() + { + var options = new SquadAgentOptions + { + ConfigureCopilotClient = _ => { } + }; + + var json = JsonSerializer.Serialize(options); + + Assert.DoesNotContain("ConfigureCopilotClient", json); + } + + [Fact] + public void JsonSerialize_IncludesNonSensitiveProperties() + { + var options = new SquadAgentOptions + { + SquadFolderPath = @"C:\team", + AgentName = "TestAgent", + TraceEvents = true + }; + + var json = JsonSerializer.Serialize(options); + + Assert.Contains("TestAgent", json); + Assert.Contains(@"C:\\team", json); + } +} diff --git a/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs b/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs new file mode 100644 index 000000000..5beff8142 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadAgentRoutingTests.cs @@ -0,0 +1,198 @@ +using System.Reflection; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Squad.Agents.AI.Tests; + +public class SquadAgentRoutingTests +{ + [Fact] + public void AddSquadAgent_ResolvesAIAgentWithConfiguredPersonaName() + { + var provider = BuildProvider(options => + { + options.AgentName = "Custom Coordinator"; + }); + + var agent = provider.GetRequiredService(); + var squadAgent = Assert.IsType(agent); + + Assert.Equal("Custom Coordinator", squadAgent.Name); + } + + [Fact] + public void AddSquadAgent_PassesBoundaryInstructionsToInnerSessionConfig() + { + const string instructions = "You are a coordinator. Stay inside the configured boundary."; + var agent = CreateAgent(options => + { + options.Instructions = instructions; + }); + + var sessionConfig = GetInnerSessionConfig(agent); + var systemMessage = GetRequiredProperty(sessionConfig, "SystemMessage"); + + Assert.Equal(instructions, GetRequiredProperty(systemMessage, "Content")); + } + + [Fact] + public void AddSquadAgent_UsesExplicitCwdForCopilotClientRouting() + { + var agent = CreateAgent(options => + { + options.SquadFolderPath = @"C:\squad-team-root"; + options.Cwd = @"C:\isolated-working-directory"; + }); + + var clientOptions = GetCopilotClientOptions(agent); + + Assert.Equal(@"C:\isolated-working-directory", GetRequiredProperty(clientOptions, "Cwd")); + } + + [Fact] + public void AddSquadAgent_DefaultsCopilotClientCwdToSquadFolderPath() + { + var agent = CreateAgent(options => + { + options.SquadFolderPath = @"C:\squad-team-root"; + options.Cwd = null; + }); + + var clientOptions = GetCopilotClientOptions(agent); + + Assert.Equal(@"C:\squad-team-root", GetRequiredProperty(clientOptions, "Cwd")); + } + + [Fact] + public void AddSquadAgent_RoutesThroughCopilotClientOptionsNotAgentName() + { + var agent = CreateAgent(options => + { + options.AgentName = "Custom Persona"; + options.CliArgs.Clear(); + options.CliArgs.Add("--extension"); + options.CliArgs.Add("squad"); + options.Environment["SQUAD_ROUTE"] = "enabled"; + }); + + var inner = GetInnerAgent(agent); + var sessionConfig = GetInnerSessionConfig(agent); + var clientOptions = GetCopilotClientOptions(agent); + var cliArgs = GetRequiredProperty(clientOptions, "CliArgs"); + var environment = GetRequiredProperty>(clientOptions, "Environment"); + + Assert.Equal("Custom Persona", GetRequiredProperty(inner, "Name")); + Assert.True(string.IsNullOrEmpty(GetProperty(sessionConfig, "Agent"))); + Assert.DoesNotContain("Custom Persona", cliArgs); + Assert.Equal("enabled", environment["SQUAD_ROUTE"]); + } + + [Fact] + public void AddSquadAgent_CopiesConnectionStringCliArgsToCopilotClientOptions() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = "squad://localhost?teamRoot=C:%5Csquad-team-root&cliArgs=--yolo;--model;gpt-5" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(options => + { + options.CliPath = @"C:\fake-copilot\copilot.exe"; + options.GitHubToken = "test-token"; + }); + + var provider = services.BuildServiceProvider(); + var agent = provider.GetRequiredService(); + var clientOptions = GetCopilotClientOptions(agent); + var cliArgs = GetRequiredProperty(clientOptions, "CliArgs"); + + Assert.Equal(new[] { "--yolo", "--model", "gpt-5" }, cliArgs); + } + + private static SquadAgent CreateAgent(Action? configure = null) + { + var provider = BuildProvider(configure); + return provider.GetRequiredService(); + } + + private static ServiceProvider BuildProvider(Action? configure = null) + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(options => + { + options.AgentName = "Squad"; + options.SquadFolderPath = @"C:\squad-team-root"; + options.CliPath = @"C:\fake-copilot\copilot.exe"; + options.Cwd = @"C:\squad-team-root"; + options.GitHubToken = "test-token"; + options.CliArgs.Add("--extension"); + options.CliArgs.Add("squad"); + options.Environment["SQUAD_TEST"] = "true"; + options.Instructions = "Default test boundary instructions."; + + configure?.Invoke(options); + }); + + return services.BuildServiceProvider(); + } + + private static object GetCopilotClientOptions(SquadAgent agent) + { + var client = GetRequiredField(agent, "_copilotClient"); + return GetRequiredField(client, "_options"); + } + + private static object GetInnerAgent(SquadAgent agent) + { + // InnerAgent is protected on DelegatingAIAgent; search the declaring base type. + var prop = typeof(DelegatingAIAgent).GetProperty( + "InnerAgent", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Assert.NotNull(prop); + var value = prop.GetValue(agent); + Assert.NotNull(value); + return value!; + } + + private static object GetInnerSessionConfig(SquadAgent agent) + { + var inner = GetInnerAgent(agent); + return GetRequiredField(inner, "_sessionConfig"); + } + + private static T GetRequiredField(object instance, string fieldName) + { + var field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var value = field.GetValue(instance); + Assert.NotNull(value); + return (T)value; + } + + private static T GetRequiredProperty(object instance, string propertyName) + { + var value = GetProperty(instance, propertyName); + Assert.NotNull(value); + return value; + } + + private static T? GetProperty(object instance, string propertyName) + { + var property = instance.GetType().GetProperty( + propertyName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Assert.NotNull(property); + return (T?)property.GetValue(instance); + } +} diff --git a/test/Squad.Agents.AI.Tests/SquadBYOKTests.cs b/test/Squad.Agents.AI.Tests/SquadBYOKTests.cs new file mode 100644 index 000000000..26ab0b102 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadBYOKTests.cs @@ -0,0 +1,189 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Squad.Agents.AI.Tests; + +/// +/// Validates ConfigureCopilotClient delegate behavior and routing gate +/// (Picard Condition 1, Worf SC-3). +/// +public class SquadBYOKTests +{ + [Fact] + public void ConfigureCopilotClient_DelegateIsInvoked() + { + bool delegateCalled = false; + var agent = CreateAgent(opts => + { + opts.ConfigureCopilotClient = clientOpts => + { + delegateCalled = true; + }; + }); + + Assert.True(delegateCalled); + } + + [Fact] + public void ConfigureCopilotClient_CanSetEnvironmentOnCopilotClient() + { + var agent = CreateAgent(opts => + { + opts.ConfigureCopilotClient = clientOpts => + { + clientOpts.Environment = new Dictionary + { + ["CUSTOM_VAR"] = "custom-value" + }; + }; + }); + + var clientOptions = GetCopilotClientOptions(agent); + var env = GetRequiredProperty>(clientOptions, "Environment"); + + Assert.Equal("custom-value", env["CUSTOM_VAR"]); + } + + [Fact] + public void ConfigureCopilotClient_RestoresCwdAfterDelegate() + { + var agent = CreateAgent(opts => + { + opts.Cwd = @"C:\original-cwd"; + opts.ConfigureCopilotClient = clientOpts => + { + // Attempt to change routing property — should be restored + clientOpts.Cwd = @"C:\hijacked-cwd"; + }; + }); + + var clientOptions = GetCopilotClientOptions(agent); + var cwd = GetRequiredProperty(clientOptions, "Cwd"); + + Assert.Equal(@"C:\original-cwd", cwd); + } + + [Fact] + public void ConfigureCopilotClient_RestoresCliPathAfterDelegate() + { + var agent = CreateAgent(opts => + { + opts.CliPath = @"C:\original\copilot.exe"; + opts.ConfigureCopilotClient = clientOpts => + { + clientOpts.CliPath = @"C:\hijacked\evil.exe"; + }; + }); + + var clientOptions = GetCopilotClientOptions(agent); + var cliPath = GetProperty(clientOptions, "CliPath"); + + Assert.Equal(@"C:\original\copilot.exe", cliPath); + } + + [Fact] + public void ConfigureCopilotClient_RestoresCliArgsAfterDelegate() + { + var agent = CreateAgent(opts => + { + opts.CliArgs.Add("--extension"); + opts.CliArgs.Add("squad"); + opts.ConfigureCopilotClient = clientOpts => + { + clientOpts.CliArgs = new[] { "--hijacked" }; + }; + }); + + var clientOptions = GetCopilotClientOptions(agent); + var cliArgs = GetRequiredProperty(clientOptions, "CliArgs"); + + Assert.Contains("--extension", cliArgs); + Assert.Contains("squad", cliArgs); + Assert.DoesNotContain("--hijacked", cliArgs); + } + + [Fact] + public void ConfigureCopilotClient_AllowsTokenOverride() + { + var agent = CreateAgent(opts => + { + opts.GitHubToken = "original-token"; + opts.ConfigureCopilotClient = clientOpts => + { + // Token override IS allowed (BYOK use case) + clientOpts.GitHubToken = "byok-token"; + }; + }); + + var clientOptions = GetCopilotClientOptions(agent); + var token = GetProperty(clientOptions, "GitHubToken"); + + Assert.Equal("byok-token", token); + } + + [Fact] + public void ConfigureCopilotClient_NullDelegate_DoesNotThrow() + { + var agent = CreateAgent(opts => + { + opts.ConfigureCopilotClient = null; + }); + + Assert.NotNull(agent); + } + + private static SquadAgent CreateAgent(Action? configure = null) + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(options => + { + options.AgentName = "BYOK Test"; + options.SquadFolderPath = @"C:\squad-team-root"; + options.CliPath = @"C:\fake-copilot\copilot.exe"; + options.Cwd = @"C:\squad-team-root"; + options.GitHubToken = "test-token"; + options.CliArgs.Add("--extension"); + options.CliArgs.Add("squad"); + + configure?.Invoke(options); + }); + + return services.BuildServiceProvider().GetRequiredService(); + } + + private static object GetCopilotClientOptions(SquadAgent agent) + { + var client = GetRequiredField(agent, "_copilotClient"); + return GetRequiredField(client, "_options"); + } + + private static T GetRequiredField(object instance, string fieldName) + { + var field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var value = field.GetValue(instance); + Assert.NotNull(value); + return (T)value; + } + + private static T GetRequiredProperty(object instance, string propertyName) + { + var value = GetProperty(instance, propertyName); + Assert.NotNull(value); + return value; + } + + private static T? GetProperty(object instance, string propertyName) + { + var property = instance.GetType().GetProperty( + propertyName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Assert.NotNull(property); + return (T?)property.GetValue(instance); + } +} diff --git a/test/Squad.Agents.AI.Tests/SquadConnectionFactoryTests.cs b/test/Squad.Agents.AI.Tests/SquadConnectionFactoryTests.cs new file mode 100644 index 000000000..7bad1225f --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadConnectionFactoryTests.cs @@ -0,0 +1,77 @@ +using Xunit; + +namespace Squad.Agents.AI.Tests; + +public class SquadConnectionFactoryTests +{ + [Fact] + public void FromConnectionString_WithPathForm_ParsesPathOnly() + { + var connectionString = @"C:\team-root"; + var options = SquadConnectionFactory.FromConnectionString(connectionString); + + Assert.Equal(@"C:\team-root", options.SquadFolderPath); + Assert.Equal(@"C:\team-root", options.Cwd); + Assert.Null(options.CliPath); + } + + [Fact] + public void FromConnectionString_WithUnixPathForm_ParsesPathOnly() + { + var connectionString = "/Users/me/team-root"; + var options = SquadConnectionFactory.FromConnectionString(connectionString); + + Assert.Equal("/Users/me/team-root", options.SquadFolderPath); + Assert.Equal("/Users/me/team-root", options.Cwd); + Assert.Null(options.CliPath); + } + + [Fact] + public void FromConnectionString_WithUriForm_ParsesPathAndUri() + { + var connectionString = "squad://localhost?teamRoot=C:%5Cteam-root&cliPath=C:%5Cbin%5Ccopilot.exe&cwd=C:%5Cwork"; + var options = SquadConnectionFactory.FromConnectionString(connectionString); + + Assert.Equal(@"C:\team-root", options.SquadFolderPath); + Assert.Equal(@"C:\bin\copilot.exe", options.CliPath); + Assert.Equal(@"C:\work", options.Cwd); + } + + [Fact] + public void FromConnectionString_WithCliArgs_ParsesArgsList() + { + var connectionString = "squad://localhost?teamRoot=/team&cliArgs=--verbose;--trace"; + var options = SquadConnectionFactory.FromConnectionString(connectionString); + + Assert.Contains("--verbose", options.CliArgs); + Assert.Contains("--trace", options.CliArgs); + } + + [Fact] + public void FromConnectionString_WithEnvironmentVars_ParsesEnvDict() + { + var connectionString = "squad://localhost?teamRoot=/team&env=KEY1=value1;KEY2=value2"; + var options = SquadConnectionFactory.FromConnectionString(connectionString); + + Assert.Equal("value1", options.Environment["KEY1"]); + Assert.Equal("value2", options.Environment["KEY2"]); + } + + [Fact] + public void FromConnectionString_WithEmptyString_ThrowsArgumentException() + { + Assert.Throws(() => SquadConnectionFactory.FromConnectionString("")); + } + + [Fact] + public void FromConnectionString_WithNullString_ThrowsArgumentException() + { + Assert.Throws(() => SquadConnectionFactory.FromConnectionString(null!)); + } + + [Fact] + public void FromConnectionString_WithWhitespace_ThrowsArgumentException() + { + Assert.Throws(() => SquadConnectionFactory.FromConnectionString(" ")); + } +} diff --git a/test/Squad.Agents.AI.Tests/SquadKeyedDITests.cs b/test/Squad.Agents.AI.Tests/SquadKeyedDITests.cs new file mode 100644 index 000000000..a77804826 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadKeyedDITests.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Squad.Agents.AI.Tests; + +/// +/// Validates keyed DI registration and resolution (Round 2 feature). +/// +public class SquadKeyedDITests +{ + [Fact] + public void AddKeyedSquadAgent_ResolvesViaKeyedService() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddKeyedSquadAgent("research", opts => + { + opts.SquadFolderPath = @"C:\research-team"; + opts.CliPath = @"C:\fake\copilot.exe"; + opts.GitHubToken = "test-token"; + }); + + var provider = services.BuildServiceProvider(); + var agent = provider.GetRequiredKeyedService("research"); + + Assert.NotNull(agent); + Assert.Equal("Squad", agent.Name); + } + + [Fact] + public void AddKeyedSquadAgent_TwoKeys_ResolveSeparately() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddKeyedSquadAgent("research", opts => + { + opts.AgentName = "Research Squad"; + opts.SquadFolderPath = @"C:\research"; + opts.CliPath = @"C:\fake\copilot.exe"; + opts.GitHubToken = "test-token"; + }); + services.AddKeyedSquadAgent("platform", opts => + { + opts.AgentName = "Platform Squad"; + opts.SquadFolderPath = @"C:\platform"; + opts.CliPath = @"C:\fake\copilot.exe"; + opts.GitHubToken = "test-token"; + }); + + var provider = services.BuildServiceProvider(); + var research = provider.GetRequiredKeyedService("research"); + var platform = provider.GetRequiredKeyedService("platform"); + + Assert.Equal("Research Squad", research.Name); + Assert.Equal("Platform Squad", platform.Name); + } + + [Fact] + public void AddKeyedSquadAgent_WithConnectionString_BindsOptions() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad-research"] = @"C:\conn-str-team" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddKeyedSquadAgent("research", opts => + { + opts.CliPath = @"C:\fake\copilot.exe"; + opts.GitHubToken = "test-token"; + }); + + var provider = services.BuildServiceProvider(); + var agent = provider.GetRequiredKeyedService("research"); + + Assert.NotNull(agent); + } + + [Fact] + public void AddKeyedSquadAgent_DoesNotRegisterNonKeyed() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddKeyedSquadAgent("research", opts => + { + opts.SquadFolderPath = @"C:\research"; + opts.CliPath = @"C:\fake\copilot.exe"; + opts.GitHubToken = "test-token"; + }); + + var provider = services.BuildServiceProvider(); + var nonKeyed = provider.GetService(); + + // Keyed registration should NOT be resolvable via non-keyed + Assert.Null(nonKeyed); + } + + [Fact] + public void AddKeyedSquadAgent_ThrowsOnNullOrWhitespaceKey() + { + var services = new ServiceCollection(); + + Assert.Throws(() => + services.AddKeyedSquadAgent("", opts => { })); + + Assert.Throws(() => + services.AddKeyedSquadAgent(" ", opts => { })); + } +} diff --git a/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs b/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..0ba8745f3 --- /dev/null +++ b/test/Squad.Agents.AI.Tests/SquadServiceCollectionExtensionsTests.cs @@ -0,0 +1,190 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Squad.Agents.AI.Tests; + +public class SquadServiceCollectionExtensionsTests +{ + [Fact] + public void AddSquadAgent_WithoutConfig_ThrowsOnResolution() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(); + + var provider = services.BuildServiceProvider(); + + // SquadFolderPath not set → InvalidOperationException on resolution (MAF ctor pattern). + Assert.Throws(() => provider.GetRequiredService()); + } + + [Fact] + public void AddSquadAgent_WithConnectionString_BindsSquadFolderPath() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = @"C:\team-root" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(@"C:\team-root", options.SquadFolderPath); + Assert.Equal(@"C:\team-root", options.Cwd); + } + + [Fact] + public void AddSquadAgent_WithName_BindsNamedConnectionString() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = @"C:\default-team-root", + ["ConnectionStrings:squad-research"] = @"C:\research-team-root" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent("research"); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get("research"); + + Assert.Equal(@"C:\research-team-root", options.SquadFolderPath); + Assert.Equal(@"C:\research-team-root", options.Cwd); + } + + [Fact] + public void AddSquadAgent_WithName_UserCallbackOverridesNamedConnectionString() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad-research"] = @"C:\research-team-root" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent("research", opts => + { + opts.SquadFolderPath = @"C:\override-team-root"; + }); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get("research"); + + Assert.Equal(@"C:\override-team-root", options.SquadFolderPath); + } + + [Fact] + public void AddSquadAgent_UserCallbackOverridesConnectionString() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = @"C:\team-root" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(opts => + { + opts.SquadFolderPath = @"C:\user-override"; + }); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(@"C:\user-override", options.SquadFolderPath); + } + + [Fact] + public void AddSquadAgent_WithUriConnectionString_ParsesAllFields() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = "squad://localhost?teamRoot=C:%5Cteam&cliPath=C:%5Cbin%5Ccopilot.exe" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(@"C:\team", options.SquadFolderPath); + Assert.Equal(@"C:\bin\copilot.exe", options.CliPath); + } + + [Fact] + public void AddSquadAgent_WithNoConnectionString_DoesNotThrow() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + // Should get default SquadAgentOptions + Assert.Null(options.SquadFolderPath); + } + + [Fact] + public void AddSquadAgent_MergesCliArgsFromConnectionString() + { + var services = new ServiceCollection(); + var configDict = new Dictionary + { + ["ConnectionStrings:squad"] = "squad://localhost?teamRoot=/team&cliArgs=--verbose" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configDict) + .Build(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddSquadAgent(opts => + { + opts.CliArgs.Add("--user-arg"); + }); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Contains("--verbose", options.CliArgs); + Assert.Contains("--user-arg", options.CliArgs); + } +}