` and try again.
+**If the script fails with "No fix files detected":** Report as `Blocked` — do NOT switch branches.
**If something fails mid-attempt:** `pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore`
@@ -318,9 +315,10 @@ git diff | Set-Content "$OUTPUT_DIR/fix.diff"
```bash
pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore
-git checkout HEAD -- .
```
+🚨 Do NOT use `git checkout HEAD -- .` or `git clean` to restore — use the script.
+
### Step 9: Report Results
Provide structured output to the invoker:
@@ -349,29 +347,6 @@ Provide structured output to the invoker:
**Determining Status:** Set `Done` when you've completed testing this approach (whether it passed or failed). Set `NeedsRetry` only if you hit a transient error (network timeout, flaky test) and want to retry the same approach.
-### Step 10: Update State File (if provided)
-
-If `state_file` input was provided and file exists:
-
-1. **Read current Fix Candidates table** from state file
-2. **Determine next attempt number** (count existing try-fix rows + 1)
-3. **Append new row** with this attempt's results:
-
-| # | Source | Approach | Test Result | Files Changed | Notes |
-|---|--------|----------|-------------|---------------|-------|
-| N | try-fix #N | [approach] | ✅ PASS / ❌ FAIL | [files] | [analysis] |
-
-**If no state file provided:** Skip this step (results returned to invoker only).
-
-**⚠️ Do NOT `git add` or `git commit` the state file.** It lives in `CustomAgentLogsTmp/` which is `.gitignore`d. Committing it with `git add -f` would cause `git checkout HEAD -- .` (used between phases) to revert it, losing data.
-
-**⚠️ IMPORTANT: Do NOT set any "Exhausted" field.** Cross-pollination exhaustion is determined by the pr agent after invoking ALL 6 models and confirming none have new ideas. try-fix only reports its own attempt result.
-
-**Ownership rule:** try-fix updates its own row ONLY. Never modify:
-- Phase status fields
-- "Selected Fix" field
-- Other try-fix rows
-
## Error Handling
| Situation | Action |
diff --git a/.github/skills/verify-tests-fail-without-fix/SKILL.md b/.github/skills/verify-tests-fail-without-fix/SKILL.md
index ba3df1d1aaa6..201258220d4d 100644
--- a/.github/skills/verify-tests-fail-without-fix/SKILL.md
+++ b/.github/skills/verify-tests-fail-without-fix/SKILL.md
@@ -94,7 +94,7 @@ The script auto-detects which mode to use based on whether fix files are present
7. Runs tests (should PASS with fix)
8. **Generates markdown reports**:
- `CustomAgentLogsTmp/TestValidation/verification-report.md` - Full detailed report
- - `CustomAgentLogsTmp/PRState/verification-report.md` - Gate section for PR agent
+ - `CustomAgentLogsTmp/PRState/verification-report.md` - Validate section for agent
9. **Updates PR labels** based on result
10. Reports result
diff --git a/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 b/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1
index de3bad6fee15..5eacfed3135a 100644
--- a/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1
+++ b/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1
@@ -126,14 +126,14 @@ if (-not $PRNumber) {
}
if (-not $foundPR) {
- Write-Host "⚠️ Could not auto-detect PR number - using 'unknown' folder" -ForegroundColor Yellow
- $PRNumber = "unknown"
+ Write-Error "Could not auto-detect PR number. Please provide -PRNumber parameter."
+ exit 1
}
}
}
# Set output directory based on PR number
-$OutputDir = "CustomAgentLogsTmp/PRState/$PRNumber/verify-tests-fail"
+$OutputDir = "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/gate/verify-tests-fail"
Write-Host "📁 Output directory: $OutputDir" -ForegroundColor Cyan
# ============================================================
@@ -144,59 +144,6 @@ $BaselineScript = Join-Path $RepoRoot ".github/scripts/EstablishBrokenBaseline.p
# Import Test-IsTestFile and Find-MergeBase from shared script
. $BaselineScript
-# ============================================================
-# Label management for verification results
-# ============================================================
-$LabelConfirmed = "s/ai-reproduction-confirmed"
-$LabelFailed = "s/ai-reproduction-failed"
-
-function Update-VerificationLabels {
- param(
- [Parameter(Mandatory = $true)]
- [bool]$ReproductionConfirmed,
-
- [Parameter(Mandatory = $false)]
- [string]$PR = $PRNumber
- )
-
- if ($PR -eq "unknown" -or -not $PR) {
- Write-Host "⚠️ Cannot update labels: PR number not available" -ForegroundColor Yellow
- return
- }
-
- $labelToAdd = if ($ReproductionConfirmed) { $LabelConfirmed } else { $LabelFailed }
- $labelToRemove = if ($ReproductionConfirmed) { $LabelFailed } else { $LabelConfirmed }
-
- Write-Host ""
- Write-Host "🏷️ Updating verification labels on PR #$PR..." -ForegroundColor Cyan
-
- # Track success for both operations
- $removeSuccess = $true
-
- # Remove the opposite label if it exists (using REST API to avoid GraphQL deprecation issues)
- $existingLabels = gh pr view $PR --json labels --jq '.labels[].name' 2>$null
- if ($existingLabels -contains $labelToRemove) {
- Write-Host " Removing: $labelToRemove" -ForegroundColor Yellow
- gh api "repos/dotnet/maui/issues/$PR/labels/$labelToRemove" --method DELETE 2>$null | Out-Null
- if ($LASTEXITCODE -ne 0) {
- $removeSuccess = $false
- Write-Host " ⚠️ Failed to remove label: $labelToRemove" -ForegroundColor Yellow
- }
- }
-
- # Add the appropriate label (using REST API to avoid GraphQL deprecation issues)
- Write-Host " Adding: $labelToAdd" -ForegroundColor Green
- $result = gh api "repos/dotnet/maui/issues/$PR/labels" --method POST -f "labels[]=$labelToAdd" 2>&1
- $addSuccess = $LASTEXITCODE -eq 0
-
- if ($addSuccess -and $removeSuccess) {
- Write-Host "✅ Labels updated successfully" -ForegroundColor Green
- } elseif ($addSuccess) {
- Write-Host "⚠️ Label added but failed to remove old label" -ForegroundColor Yellow
- } else {
- Write-Host "⚠️ Failed to update labels: $result" -ForegroundColor Yellow
- }
-}
# ============================================================
# Auto-detect test filter from changed files
@@ -466,7 +413,6 @@ if ($DetectedFixFiles.Count -eq 0) {
Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Green
Write-Host ""
Write-Host "Failed tests: $($testResult.FailCount)" -ForegroundColor Yellow
- Update-VerificationLabels -ReproductionConfirmed $true
exit 0
} else {
# Tests PASSED - this is bad!
@@ -487,7 +433,6 @@ if ($DetectedFixFiles.Count -eq 0) {
Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Red
Write-Host ""
Write-Host "Passed tests: $($testResult.PassCount)" -ForegroundColor Yellow
- Update-VerificationLabels -ReproductionConfirmed $false
exit 1
}
}
@@ -882,9 +827,7 @@ if ($verificationPassed) {
Write-Host "╠═══════════════════════════════════════════════════════════╣" -ForegroundColor Green
Write-Host "║ Tests correctly detect the issue: ║" -ForegroundColor Green
Write-Host "║ - FAIL without fix (as expected) ║" -ForegroundColor Green
- Write-Host "║ - PASS with fix (as expected) ║" -ForegroundColor Green
Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Green
- Update-VerificationLabels -ReproductionConfirmed $true
exit 0
} else {
Write-Host ""
@@ -904,8 +847,6 @@ if ($verificationPassed) {
Write-Host "║ 1. Wrong fix files specified ║" -ForegroundColor Red
Write-Host "║ 2. Tests don't actually test the fixed behavior ║" -ForegroundColor Red
Write-Host "║ 3. The issue was already fixed in base branch ║" -ForegroundColor Red
- Write-Host "║ 4. Build caching - try clean rebuild ║" -ForegroundColor Red
Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Red
- Update-VerificationLabels -ReproductionConfirmed $false
exit 1
}
diff --git a/.github/workflows/bump-global-json.yml b/.github/workflows/bump-global-json.yml
new file mode 100644
index 000000000000..48ee3df11bce
--- /dev/null
+++ b/.github/workflows/bump-global-json.yml
@@ -0,0 +1,43 @@
+name: Bump global.json for dotnet/dotnet bumps
+on: pull_request_target
+
+jobs:
+ bump-global-json:
+ name: Bump global.json
+ runs-on: ubuntu-latest
+ # GITHUB_TOKEN change from read-write to read-only on 2024-02-01 requires permissions block
+ # https://docs.opensource.microsoft.com/github/apps/permission-changes/
+ # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs
+ permissions:
+ contents: write
+ if: contains(github.event.pull_request.title, 'Update dependencies from dotnet/') && github.actor == 'dotnet-maestro[bot]'
+ steps:
+ - name: 'Checkout repo'
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ repository: ${{ github.event.pull_request.head.repo.full_name }}
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: 'Update global.json'
+ env:
+ PR_NUMBER: ${{ github.event.number }}
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ run: |
+ set -exo pipefail
+
+ sudo apt-get install libxml2-utils
+ DOTNET_VERSION=$(xmllint --xpath '/Project/PropertyGroup/MicrosoftNETSdkPackageVersion/text()' eng/Versions.props)
+
+ jq '.tools.dotnet = "'$DOTNET_VERSION'"' global.json > global.json.tmp
+ mv global.json.tmp global.json
+ if git diff --exit-code -- global.json; then
+ echo "No global.json update necessary"
+ exit 0
+ fi
+ git add -- global.json
+ git config --global user.email "github-actions@xamarin.com"
+ git config --global user.name "GitHub Actions"
+ git checkout "$GITHUB_HEAD_REF"
+ git commit -m "Re-generate global.json for PR #$PR_NUMBER: $PR_TITLE"
+ git push
diff --git a/.github/workflows/dogfood-comment.yml b/.github/workflows/dogfood-comment.yml
index b2a823d75d64..c79b361dacb5 100644
--- a/.github/workflows/dogfood-comment.yml
+++ b/.github/workflows/dogfood-comment.yml
@@ -1,9 +1,16 @@
name: Add Dogfooding Comment
on:
- # Trigger when the maui-pr build check completes
- check_run:
- types: [completed]
+ # Use pull_request_target to run in the context of the base branch
+ # This allows commenting on PRs from forks
+ # Note: check_run trigger doesn't work because Azure DevOps check runs
+ # don't populate the pull_requests[] field, so we can't get the PR number.
+ pull_request_target:
+ types: [opened, reopened, synchronize]
+ branches:
+ - 'main'
+ - 'net*'
+ - 'release/**'
# Allow manual triggering
workflow_dispatch:
@@ -15,23 +22,13 @@ on:
# Ensure only one instance runs at a time per PR to prevent duplicate comments
concurrency:
- group: dogfood-comment-${{ github.event.check_run.pull_requests[0].number || github.event.inputs.pr_number || 'unknown' }}
+ group: dogfood-comment-${{ github.event.pull_request.number || github.event.inputs.pr_number }}
cancel-in-progress: true
jobs:
add-dogfood-comment:
- # Only run on the dotnet org, for the maui-pr check, when it completes successfully
- if: |
- github.repository_owner == 'dotnet' &&
- (
- github.event_name == 'workflow_dispatch' ||
- (
- github.event_name == 'check_run' &&
- github.event.check_run.name == 'maui-pr (Pack .NET MAUI Pack Windows)' &&
- github.event.check_run.conclusion == 'success' &&
- github.event.check_run.pull_requests[0] != null
- )
- )
+ # Only run on the dotnet org to avoid running on forks
+ if: ${{ github.repository_owner == 'dotnet' }}
runs-on: ubuntu-latest
permissions:
pull-requests: write
@@ -41,8 +38,8 @@ jobs:
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
- // Get PR number from either the check_run event or manual input
- const prNumber = context.payload.check_run?.pull_requests?.[0]?.number || context.payload.inputs?.pr_number;
+ // Get PR number from either the PR event or manual input
+ const prNumber = context.payload.pull_request?.number || context.payload.inputs?.pr_number;
const bashScript = 'https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh';
const psScript = 'https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1';
diff --git a/.gitignore b/.gitignore
index 598def77e641..daafde085962 100644
--- a/.gitignore
+++ b/.gitignore
@@ -388,3 +388,6 @@ temp
# TypeScript source map files (generated artifacts)
# Note: CSS map files in templates (e.g., bootstrap) are intentionally tracked
*.js.map
+
+# Gradle build reports
+src/Core/AndroidNative/build/reports/
diff --git a/README.md b/README.md
index 8686c2bca7cd..fce9648bdbf0 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,5 @@
# .NET Multi-platform App UI (.NET MAUI)
-[](https://dev.azure.com/dnceng-public/public/_build?definitionId=302) [](https://devdiv.visualstudio.com/DevDiv/_build/latest?definitionId=13330&repoName=dotnet%2Fmaui&branchName=main)
-
[.NET Multi-platform App UI (.NET MAUI)](https://dotnet.microsoft.com/en-us/apps/maui) is a cross-platform framework for creating mobile and desktop apps with C# and XAML. Using .NET MAUI, you can develop apps that can run on Android, iOS, iPadOS, macOS, and Windows from a single shared codebase.
## Getting Started ##
diff --git a/docs/design/cli.md b/docs/design/cli.md
index d2477d8a6370..c61fbb07a61f 100644
--- a/docs/design/cli.md
+++ b/docs/design/cli.md
@@ -1,18 +1,21 @@
---
-description: "Design document for the dotnet-maui CLI tool for AI-assisted development"
+description: "Design document for the maui CLI tool"
date: 2026-01-07
+updated: 2026-02-26
---
-# dotnet-maui CLI Design Document
+# `maui` CLI Design Document
## Overview
-The `dotnet-maui` CLI is a command-line tool that provides simple
-commands for capturing screenshots, viewing logs, and inspecting the
-visual tree of running .NET MAUI applications. While designed to
-enable AI agents to iteratively develop and validate applications,
-these commands are equally useful for developers who want quick access
-to debugging and inspection capabilities from the terminal.
+The `maui` CLI is a command-line tool for .NET MAUI development that provides two main capabilities:
+
+1. **Environment setup** — manages Android SDK/JDK, Xcode runtimes, simulators, and emulators
+2. **App inspection** — captures screenshots, streams logs, and inspects the visual tree of running apps
+
+It is designed for three consumers: **AI agents**, **CI/CD pipelines**, and **humans**.
+
+**Full specification**: [PR #33865](https://github.com/dotnet/maui/pull/33865) — covers architecture, error contracts, IDE integration, JSON schemas, and vNext roadmap.
## Motivation
@@ -24,47 +27,49 @@ simctl io booted screenshot`, while Android uses `adb exec-out
screencap`. Similarly, log access, visual tree inspection, and device
management all have platform-specific implementations.
-The `dotnet-maui` CLI provides a unified interface across Android,
+The `maui` CLI provides a unified interface across Android,
iOS, macOS, Windows, and Mac Catalyst, making these operations simple
and consistent for both developers and AI agents.
+### Design Principles
+
+1. **Delegate to native toolchains** — wraps `sdkmanager`, `adb`, `xcrun simctl`, etc.
+2. **Reuse shared libraries** — leverages [`dotnet/android-tools`](https://github.com/dotnet/android-tools) (`Xamarin.Android.Tools.AndroidSdk`) for SDK/JDK discovery, and contributes new capabilities (JDK installation, SDK bootstrap, license acceptance) back to it.
+3. **Machine-first output** — every command supports `--json`
+4. **Stateless** — each command reads state, acts, and exits
+5. **Complement `dotnet run`** — uses the same device identifiers and framework options as [`dotnet run` for .NET MAUI][dotnet-run-spec]
+
## Goals
-1. **Screenshot capture**: Enable AI agents to capture screenshots of
+1. **Environment setup**: Manage Android SDK/JDK, Xcode runtimes,
+ simulators, and emulators from a single tool
+
+2. **Screenshot capture**: Enable AI agents to capture screenshots of
running .NET MAUI applications to validate visual changes
-2. **Log access**: Provide unified access to platform-specific device
+3. **Log access**: Provide unified access to platform-specific device
logs (logcat, Console, etc.)
-3. **Visual tree inspection**: Allow agents to inspect the runtime
+4. **Visual tree inspection**: Allow agents to inspect the runtime
visual tree structure and properties (.NET MAUI visual tree)
-4. **Developer experience**: Integrate seamlessly with existing
+5. **Developer experience**: Integrate seamlessly with existing
`dotnet` CLI workflows, this should fit in with `dotnet run`,
`dotnet watch`, etc.
## Installation and Invocation
-The CLI will be available through multiple invocation methods to
-support different workflows:
-
-### Method 1: Direct Tool Invocation
+The CLI is available through multiple invocation methods:
```bash
-dotnet-maui screenshot -o screenshot.png
-```
+# Direct tool invocation (after install)
+maui screenshot -o screenshot.png
-### Method 2: .NET CLI
-
-```bash
+# Via the .NET CLI
dotnet maui screenshot -o screenshot.png
-```
-
-### Method 3: `dotnet tool exec` or `dnx`
-```bash
+# Inline install and invocation (no prior install needed)
dotnet tool exec -y Microsoft.Maui.Cli screenshot -o screenshot.png
-dnx -y Microsoft.Maui.Cli screenshot -o screenshot.png
```
### Installation
@@ -78,12 +83,10 @@ dotnet tool install Microsoft.Maui.Cli
# Restore local tools
dotnet tool restore
-
-# Inline install and invocation
-dotnet tool exec -y Microsoft.Maui.Cli screenshot -o screenshot.png
-dnx -y Microsoft.Maui.Cli screenshot -o screenshot.png
```
+The tool installs as `maui` on PATH. All commands in this document use the `maui` form.
+
The .NET workload specification includes support for automatically
installing tools from workloads via the `tools-packs` feature (see
[workload manifest specification][workload-spec]). However, this
@@ -92,15 +95,115 @@ could be automatically installed when the `maui` workload is
installed, eliminating the need for manual tool installation.
Until then, manual installation via `dotnet tool install` will be how
-we prove out the `dotnet-maui` CLI.
+we prove out the `maui` CLI.
[workload-spec]: https://github.com/dotnet/designs/blob/566ad4cafcc578d6389c215c61924ee9e07dcb29/accepted/2020/workloads/workload-manifest.md#tools-packs
-## Command Structure
+## Global Options
+
+All commands support:
+
+| Flag | Description |
+|------|-------------|
+| `--json` | Structured JSON output |
+| `--verbose` | Detailed logging |
+| `--interactive` | Control interactive prompts (default: `true` for terminals, `false` in CI or when output is redirected) |
+| `--dry-run` | Preview actions without executing |
+| `--platform ` | Filter by platform: `android`, `ios`, `maccatalyst`, `windows` |
-### Global Options
+**Interactivity detection** follows the same pattern as `dotnet` CLI — auto-detects CI environments (`TF_BUILD`, `GITHUB_ACTIONS`, `CI`, etc.) and checks `Console.IsOutputRedirected`.
-The `dotnet-maui` CLI follows the conventions established by [`dotnet
+## Environment Setup Commands
+
+### Android
+
+| Command | Description |
+|---------|-------------|
+| `maui android install` | Install JDK + SDK + recommended packages |
+| `maui android install --accept-licenses` | Non-interactive install |
+| `maui android install --packages ` | Install specific packages |
+| `maui android jdk check` | Check JDK status |
+| `maui android jdk install` | Install OpenJDK 21 |
+| `maui android jdk list` | List installed JDKs |
+| `maui android sdk list` | List installed packages |
+| `maui android sdk list --available` | Show available packages |
+| `maui android sdk install ` | Install package(s) |
+| `maui android sdk accept-licenses` | Accept all licenses |
+| `maui android sdk uninstall ` | Uninstall a package |
+| `maui android emulator list` | List emulators |
+| `maui android emulator create ` | Create emulator (auto-detects system image) |
+| `maui android emulator start ` | Start emulator |
+| `maui android emulator stop ` | Stop emulator |
+| `maui android emulator delete ` | Delete emulator |
+
+Install paths and defaults are handled by [`dotnet/android-tools`](https://github.com/dotnet/android-tools).
+
+### Apple (macOS only)
+
+| Command | Description |
+|---------|-------------|
+| `maui apple install [--accept-license] [--runtime ]` | Optionally accepts Xcode license and installs simulator runtimes. Could prompt user to install Xcode in the future |
+| `maui apple check` | Check Xcode, runtimes, and environment status |
+| `maui apple xcode check` | Check Xcode installation and license |
+| `maui apple xcode list` | List Xcode installations |
+| `maui apple xcode select ` | Switch active Xcode |
+| `maui apple xcode accept-license` | Accept Xcode license |
+| `maui apple simulator list` | List simulators |
+| `maui apple simulator create ` | Create simulator |
+| `maui apple simulator start ` | Start simulator |
+| `maui apple simulator stop ` | Stop simulator |
+| `maui apple simulator delete ` | Delete simulator |
+| `maui apple runtime check` | Check runtime status |
+| `maui apple runtime list` | List installed runtimes |
+| `maui apple runtime list --all` | List all runtimes (installed and downloadable) |
+| `maui apple runtime install ` | Install an iOS runtime |
+
+> **License flag naming**: Android uses `accept-licenses` (plural) because `sdkmanager` requires accepting multiple SDK component licenses. Apple uses `accept-license` (singular) because `xcodebuild -license accept` accepts one unified Xcode license agreement.
+
+### Implementation References
+
+The `maui` CLI delegates to shared libraries for platform operations:
+
+**Android** — [`dotnet/android-tools`](https://github.com/dotnet/android-tools) (`Xamarin.Android.Tools.AndroidSdk`):
+
+| Feature | Implementation |
+|---------|---------------|
+| SDK discovery, bootstrap & license acceptance | [`SdkManager`](https://github.com/dotnet/android-tools/pull/275) |
+| JDK discovery & installation | [`JdkInstaller`](https://github.com/dotnet/android-tools/pull/274) |
+| ADB device management | [`AdbRunner`](https://github.com/dotnet/android-tools/pull/282) |
+| AVD / Emulator management | [`AvdManagerRunner`](https://github.com/dotnet/android-tools/pull/283), [`EmulatorRunner`](https://github.com/dotnet/android-tools/pull/284) |
+
+**Apple** — wraps native toolchains directly:
+
+| Feature | Native tool |
+|---------|------------|
+| Simulator management | `xcrun simctl` (list, create, boot, shutdown, delete) |
+| Runtime management | `xcrun simctl runtime` (list, add) |
+| Xcode management | `xcode-select`, `xcodebuild -license` |
+| Device detection | `xcrun devicectl list devices` (physical), `xcrun simctl list` (simulators) |
+
+Apple operations use [AppleDev.Tools][appledev-tools] for `simctl` and `devicectl` wrappers.
+
+### Exit Codes
+
+All commands use consistent exit codes:
+
+| Code | Meaning |
+|------|---------|
+| 0 | Success |
+| 1 | General error |
+| 2 | Environment/configuration error |
+| 3 | Permission denied (elevation required) |
+| 4 | Network error (download failed) |
+| 5 | Resource not found |
+
+## App Inspection Commands (Future)
+
+> **Note**: App inspection commands are planned for a future release. The initial release focuses on environment setup and device management.
+
+### Device Selection Options
+
+App inspection commands will follow the conventions established by [`dotnet
run` for .NET MAUI][dotnet-run-spec], using the same device selection
and framework options:
@@ -146,7 +249,7 @@ Captures a screenshot of the currently running .NET MAUI application.
**Usage:**
```bash
-dotnet maui screenshot [options]
+maui screenshot [options]
```
**Options:**
@@ -158,17 +261,17 @@ dotnet maui screenshot [options]
Initial implementation targets Android and iOS/Mac Catalyst, with Windows and macOS support planned as described below.
-- **Android**: Uses `adb screencap`
-- **iOS/Mac Catalyst**: Uses `simctl io screenshot` for simulator, and device capture via Xcode tooling (future implementation)
+- **Android**: Uses `adb exec-out screencap -p`
+- **iOS/Mac Catalyst**: Uses `xcrun simctl io booted screenshot ` for simulator; physical device capture via Xcode tooling (future)
- **Windows** (planned): Uses Windows screen capture APIs to capture the active app window or full screen.
- **macOS** (planned): Uses macOS screen capture APIs or command-line tooling to capture the active app window or full screen.
### Future Commands
-To keep scope small for initial version, future commands are:
-
-- `dotnet maui log` or `logs`
-- `dotnet maui tree` for displaying the visual tree
+- `maui device list` for unified device/emulator/simulator listing across platforms
+- `maui screenshot` for capturing screenshots of running apps
+- `maui logs` for streaming device logs
+- `maui tree` for inspecting the visual tree
## Integration with `dotnet run` and `dotnet watch`
@@ -180,12 +283,10 @@ The CLI is designed to work seamlessly with existing .NET workflows:
# Terminal 1: Run application with hot reload
dotnet watch run
-# Terminal 2: Monitor logs
-dotnet maui logs --follow --filter "MyApp"
-
-# Terminal 3: Inspect application
-dotnet maui screenshot --output iteration1.png
-dotnet maui tree --format json
+# Terminal 2: Inspect application
+maui screenshot --output iteration1.png
+maui logs --follow --filter "MyApp" # future
+maui tree --json # future
```
### AI Agent Workflow
@@ -198,13 +299,13 @@ dotnet maui tree --format json
sleep 2
# 3. Capture screenshot
-dotnet maui screenshot -o current.png
+maui screenshot -o current.png
-# 4. Analyze visual tree
-dotnet maui tree --format json
+# 4. Analyze visual tree (future)
+maui tree --json
-# 5. Check logs for errors
-dotnet maui logs --level error
+# 5. Check logs for errors (future)
+maui logs --level error
# 6. Agent analyzes outputs and decides next steps
```
@@ -220,10 +321,10 @@ dotnet maui logs --level error
### iOS / Mac Catalyst
- **Device Detection**: `xcrun simctl list devices` (simulators),
- `xcrun devicectl list devices` (physical devices)
+ `xcrun devicectl list devices` (physical devices) — via [AppleDev.Tools][appledev-tools]
- **Screenshots**: `xcrun simctl io booted screenshot `
- (simulators), iOS devices (future implementation)
+ (simulators), iOS physical devices (future)
- **Logs**: `xcrun simctl spawn booted log stream` or Console.app
(simulators), `mlaunch --logdev` (physical devices)
@@ -243,6 +344,72 @@ The CLI is designed for development and debugging scenarios only:
(like screenshots and logs), the CLI should not be usable against
production applications
+## IDE Integration
+
+The `maui` CLI and its underlying libraries are designed to be the shared backend for IDE extensions, eliminating duplicate environment detection and setup logic across tools.
+
+### Architecture
+
+```
+┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
+│ VS Code ext │ │ Visual Studio │ │ AI Agent │
+│ (vscode-maui) │ │ extension │ │ (Copilot, etc.) │
+└────────┬─────────┘ └────────┬──────────┘ └────────┬─────────┘
+ │ │ │
+ spawns CLI references NuGet spawns CLI
+ │ library directly │
+ │ │ │
+ ▼ ▼ ▼
+ ┌──────────────┐ ┌────────────────────┐ ┌──────────────┐
+ │ maui CLI │ │ android-tools │ │ maui CLI │
+ │ (process) │ │ (in-process) │ │ (--json) │
+ └──────┬───────┘ └────────┬───────────┘ └──────┬───────┘
+ │ │ │
+ └─────────┬───────────┴───────────────────────┘
+ │ spawns native tools
+ ┌───────────┼───────────┐
+ ▼ ▼ ▼
+ ┌───────────┐ ┌──────────┐ ┌──────────┐
+ │ adb │ │ xcrun │ │ Windows │
+ │ sdkmanager│ │ simctl │ │ SDK │
+ └───────────┘ └──────────┘ └──────────┘
+```
+
+### Integration Modes
+
+| Consumer | Integration | Rationale |
+|----------|------------|-----------|
+| **Visual Studio** extension | References `android-tools` NuGet package directly (in-process) | .NET extension — no serialization overhead, direct API access |
+| **VS Code** (`vscode-maui`) | Spawns `maui` CLI process, parses `--json` stdout | TypeScript extension — CLI is the natural process boundary |
+| **AI agents / CI** | Invokes `maui` CLI with `--json` | Process-based, language-agnostic |
+| **Terminal** (human) | Invokes `maui` CLI directly | Human-readable output by default, `--json` when needed |
+
+Visual Studio consumes the `Xamarin.Android.Tools.AndroidSdk` NuGet package from [`dotnet/android-tools`](https://github.com/dotnet/android-tools) directly — the same library the CLI uses internally. This avoids process overhead and gives the VS extension full API access. Non-.NET consumers (VS Code, AI agents, CI) use the CLI as the canonical interface.
+
+### How IDEs Use It
+
+| Workflow | CLI command | IDE behavior |
+|----------|------------|--------------|
+| Workspace open | `maui apple check --json`, `maui android jdk check --json` | Show environment status in status bar / problems panel |
+| Environment fix | `maui android install --json` | Display progress bar, stream `type: "progress"` messages |
+| Device picker | `maui device list --json` (future) | Populate device dropdown / selection UI |
+| Emulator launch | `maui android emulator start --json` | Show notification, update device list on completion |
+
+### Benefits
+
+- **Consistent behavior** — VS, VS Code, and CLI all use the same detection and setup logic (via shared libraries)
+- **Single maintenance point** — bug fixes in `android-tools` propagate to all consumers
+- **AI-ready** — agents use the same `--json` output that VS Code consumes
+- **Flexible integration** — .NET consumers go in-process, others use the CLI
+
+### Current Status
+
+| Integration | Status |
+|-------------|--------|
+| VS Code extension (`vscode-maui`) | ✅ In progress |
+| Visual Studio extension | Planned (vNext) |
+| GitHub Copilot / AI agents | ✅ Supported via `--json` output |
+
## Future Goals
### MCP Server
@@ -262,7 +429,8 @@ if there's demonstrated need.
### More Subcommands
-There are other .NET MAUI CLI tools such as:
+Environment setup commands (Android SDK/JDK, Xcode, emulators,
+simulators) are now included above. These were inspired by:
- .NET MAUI "Check" / "Doctor"
- https://github.com/Redth/dotnet-maui-check
@@ -270,22 +438,22 @@ There are other .NET MAUI CLI tools such as:
- Android SDK Management
- https://github.com/Redth/AndroidSdk.Tools
-These could easily be added down the road.
-
Future commands:
-- `dotnet maui log` or `logs` for viewing console output
-- `dotnet maui tree` for displaying the visual tree
+- `maui device list` for unified device listing
+- `maui logs` for viewing console output
+- `maui tree` for displaying the visual tree
+- `maui screenshot` for capturing screenshots
-**Decision**: Start with just a few subcommands and expand in the
-future.
+**Decision**: Environment setup ships first. Device listing and app inspection commands
+follow in a future release.
## References
- [vibe-wpf experiment][vibe-wpf]
- [dotnet run for .NET MAUI specification][dotnet-run-spec]
- [Workload manifest specification][workload-spec]
-- [AppleDev.Tools][appledev-tools] - Wraps simctl and xcdevice commands
+- [AppleDev.Tools][appledev-tools] - Wraps simctl and devicectl commands
- [System.CommandLine documentation](https://learn.microsoft.com/dotnet/standard/commandline/)
- [Android Debug Bridge (ADB)](https://developer.android.com/studio/command-line/adb)
- [simctl command-line tool](https://nshipster.com/simctl/)
diff --git a/eng/Build.props b/eng/Build.props
index 691d93a5904d..26f2232be3dd 100644
--- a/eng/Build.props
+++ b/eng/Build.props
@@ -31,5 +31,6 @@
CodesignRequireProvisioningProfile=false
+
diff --git a/eng/Versions.props b/eng/Versions.props
index 3fca01616d12..0a13eb47889b 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -55,11 +55,14 @@
11.0.0-preview.3.26166.111
11.0.0-preview.3.26166.111
11.0.0-preview.3.26166.111
- 10.0.1
- 10.0.1
+ 10.3.0
+ 10.3.0
11.0.0-preview.2.26103.111
- 1.0.0-preview.251204.1
+ 1.0.0-rc2
+ 1.0.0-rc2
+ 1.0.0-rc2
+ 1.0.0-preview.260225.1
36.1.99-ci.main.217
36.1.43
diff --git a/eng/cake/dotnet.cake b/eng/cake/dotnet.cake
index e8f0d2089c00..084a098460c9 100644
--- a/eng/cake/dotnet.cake
+++ b/eng/cake/dotnet.cake
@@ -269,6 +269,7 @@ Task("dotnet-test")
"**/Controls.BindingSourceGen.UnitTests.csproj",
"**/Core.UnitTests.csproj",
"**/Essentials.UnitTests.csproj",
+ "**/Essentials.AI.UnitTests.csproj",
"**/Resizetizer.UnitTests.csproj",
"**/Graphics.Tests.csproj",
"**/Compatibility.Core.UnitTests.csproj",
diff --git a/eng/helix.proj b/eng/helix.proj
index 5ba803fef9ba..565ce270a50c 100644
--- a/eng/helix.proj
+++ b/eng/helix.proj
@@ -35,6 +35,7 @@
+
diff --git a/eng/helix_xharness.proj b/eng/helix_xharness.proj
index c99d5a7526b5..ffba39148bb1 100644
--- a/eng/helix_xharness.proj
+++ b/eng/helix_xharness.proj
@@ -31,6 +31,11 @@
CollectionView;Shell;HybridWebView
+
+
+
+ AppleIntelligenceChatClient
+
@@ -119,6 +124,15 @@
Microsoft.Maui.MauiBlazorWebView.DeviceTests
src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj
+
+
+ Essentials.AI.DeviceTests
+ $(ScenariosDir)Essentials.AI.DeviceTests
+ Microsoft.Maui.Essentials.AI.DeviceTests
+ com.microsoft.maui.ai.devicetests
+ com.microsoft.maui.ai.devicetests
+ src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj
+
@@ -173,15 +187,33 @@
02:00:00
01:00:00
+
+ ios-simulator-64
+ 02:00:00
+ 01:00:00
+ xharness apple test --target "$target" --app "$app" --output-directory "$output_directory" --timeout "$timeout" --launch-timeout "$launch_timeout" --set-env="TestFilter=SkipCategories=$(AITestCategoriesToSkipOnCI)"
+
-
+
-
+
+ <_MAUIScenarioSearchMacCatalyst Include="@(_MAUIScenarioSearch)" />
+ <_MAUIScenarioSearchMacCatalyst Remove="EssentialsAI" />
+
+
+ maccatalyst
+ 02:00:00
+ 01:00:00
+ %(_MAUIScenarioSearchMacCatalyst.ScenarioDirectoryName)
+
+
+
+
maccatalyst
02:00:00
01:00:00
- %(_MAUIScenarioSearch.ScenarioDirectoryName)
+ xharness apple test --target "$target" --app "$app" --output-directory "$output_directory" --timeout "$timeout" --launch-timeout "$launch_timeout" --set-env="TestFilter=SkipCategories=$(AITestCategoriesToSkipOnCI)"
diff --git a/eng/pipelines/arcade/stage-device-tests.yml b/eng/pipelines/arcade/stage-device-tests.yml
index 8e94406db015..66bcc110990a 100644
--- a/eng/pipelines/arcade/stage-device-tests.yml
+++ b/eng/pipelines/arcade/stage-device-tests.yml
@@ -79,6 +79,9 @@ parameters:
- name: MauiBlazorWebView.DeviceTests
path: src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj
packageId: Microsoft.Maui.MauiBlazorWebView.DeviceTests
+ - name: Essentials.AI.DeviceTests
+ path: src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj
+ packageId: com.microsoft.maui.ai.devicetests
stages:
- stage: devicetests_build
@@ -571,7 +574,7 @@ stages:
# Save unpackaged publish output before packaged builds overwrite artifacts/bin
- pwsh: |
- $artifactNames = @("Controls.DeviceTests", "Core.DeviceTests", "Graphics.DeviceTests", "Essentials.DeviceTests", "MauiBlazorWebView.DeviceTests")
+ $artifactNames = @("Controls.DeviceTests", "Core.DeviceTests", "Graphics.DeviceTests", "Essentials.DeviceTests", "MauiBlazorWebView.DeviceTests", "Essentials.AI.DeviceTests")
foreach ($name in $artifactNames) {
$publishDir = Get-ChildItem -Path "$(Build.SourcesDirectory)/artifacts/bin/$name" -Filter "publish" -Recurse -Directory | Select-Object -First 1
if ($publishDir) {
@@ -604,7 +607,8 @@ stages:
@{ Name = "Core.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/Core/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Core.DeviceTests" },
@{ Name = "Graphics.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/Graphics/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Graphics.DeviceTests" },
@{ Name = "Essentials.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/Essentials/test/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Essentials.DeviceTests" },
- @{ Name = "MauiBlazorWebView.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/BlazorWebView/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/MauiBlazorWebView.DeviceTests" }
+ @{ Name = "MauiBlazorWebView.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/BlazorWebView/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/MauiBlazorWebView.DeviceTests" },
+ @{ Name = "Essentials.AI.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/AI/tests/Essentials.AI.DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Essentials.AI.DeviceTests" }
)
foreach ($project in $projects) {
diff --git a/eng/pipelines/arcade/variables.yml b/eng/pipelines/arcade/variables.yml
index d1c838bec435..d574cd0d878f 100644
--- a/eng/pipelines/arcade/variables.yml
+++ b/eng/pipelines/arcade/variables.yml
@@ -55,7 +55,7 @@ variables:
value: >-
/p:DotNetPublishUsingPipelines=true
- name: _OfficialBuildIdArgs
- value: /p:OfficialBuildId=$(BUILD.BUILDNUMBER) /p:_SkipUpdateBuildNumber=true
+ value: /p:OfficialBuildId=$(_BuildOfficialId) /p:_SkipUpdateBuildNumber=true # Use _BuildOfficialId because Arcade does MSBuild arithmetic on OfficialBuildId and BUILD.BUILDNUMBER may contain '+' which breaks parsing.
# -runtimeSourceFeed https://ci.dot.net/internal -runtimeSourceFeedKey '$(dotnetbuilds-internal-container-read-token-base64)'
# needed for signing
- name: _SignType
diff --git a/eng/pipelines/ci-copilot.yml b/eng/pipelines/ci-copilot.yml
new file mode 100644
index 000000000000..bbd7fdad5c29
--- /dev/null
+++ b/eng/pipelines/ci-copilot.yml
@@ -0,0 +1,615 @@
+# Pipeline for running GitHub Copilot PR Reviewer Agent
+# This pipeline installs the Copilot CLI and invokes the PR reviewer agent
+# to conduct automated code reviews on pull requests.
+#
+# For more information, see:
+# https://github.com/dotnet/maui/wiki/PR-Reviewer-Agent
+
+trigger: none # Manual trigger only
+
+pr: none # Not triggered by PRs
+
+parameters:
+ - name: PRNumber
+ displayName: 'Pull Request Number'
+ type: string
+ default: ''
+
+ - name: Platform
+ displayName: 'Target Platform'
+ type: string
+ default: 'android'
+ values:
+ - android
+ - ios
+
+ - name: pool
+ type: object
+ default:
+ name: Azure Pipelines
+ vmImage: ubuntu-22.04
+
+variables:
+ - template: /eng/pipelines/common/variables.yml@self
+ - name: Codeql.Enabled
+ value: false
+ - name: Codeql.SkipTaskAutoInjection
+ value: true
+ - name: APPIUM_HOME
+ value: $(System.DefaultWorkingDirectory)/.appium/
+ - name: LogDirectory
+ value: $(Build.ArtifactStagingDirectory)/logs
+
+stages:
+ - stage: ReviewPR
+ displayName: 'Review Pull Request'
+ jobs:
+ - job: CopilotReview
+ displayName: 'Run Copilot PR Reviewer Agent'
+ pool: ${{ parameters.pool }}
+ timeoutInMinutes: 360
+ steps:
+ - checkout: self
+ fetchDepth: 0
+ persistCredentials: true
+
+ - script: |
+ echo "Validating PR Number parameter..."
+ if [ -z "${{ parameters.PRNumber }}" ]; then
+ echo "##vso[task.logissue type=error]PRNumber parameter is required"
+ exit 1
+ fi
+ echo "PR Number: ${{ parameters.PRNumber }}"
+ displayName: 'Validate Parameters'
+
+ - script: |
+ echo "##vso[build.updatebuildnumber]PR ${{ parameters.PRNumber }} ${{ parameters.Platform }}"
+ displayName: 'Set Pipeline Run Title'
+
+ # Enable KVM for Android emulator on Linux (same as ui-tests-steps.yml / device-tests-steps.yml)
+ - ${{ if eq(parameters.Platform, 'android') }}:
+ - template: common/enable-kvm.yml
+
+ # Provision SDKs (same parameters as ui-tests-steps.yml)
+ - template: common/provision.yml
+ parameters:
+ skipXcode: ${{ eq(parameters.Platform, 'android') }}
+ skipProvisionator: true
+ skipJdk: ${{ ne(parameters.Platform, 'android') }}
+ skipAndroidCommonSdks: ${{ ne(parameters.Platform, 'android') }}
+ skipAndroidPlatformApis: true
+ onlyAndroidPlatformDefaultApis: true
+ skipAndroidEmulatorImages: ${{ ne(parameters.Platform, 'android') }}
+ skipAndroidCreateAvds: true
+ androidEmulatorApiLevel: '30'
+ skipSimulatorSetup: ${{ eq(parameters.Platform, 'android') }}
+ skipCertificates: true
+
+ # Install .NET and workloads via build.ps1
+ - pwsh: ./build.ps1 --target=dotnet --configuration="Release" --verbosity=diagnostic
+ displayName: 'Install .NET and workloads'
+ retryCountOnTaskFailure: 2
+ env:
+ DOTNET_TOKEN: $(dotnetbuilds-internal-container-read-token)
+ PRIVATE_BUILD: $(PrivateBuild)
+
+ - pwsh: echo "##vso[task.prependpath]$(DotNet.Dir)"
+ displayName: 'Add .NET to PATH'
+
+ # Build MSBuild tasks (required for MAUI builds)
+ - pwsh: ./build.ps1 --target=dotnet-buildtasks --configuration="Release" --verbosity=diagnostic
+ displayName: 'Build MSBuild Tasks'
+ retryCountOnTaskFailure: 1
+ env:
+ DOTNET_TOKEN: $(dotnetbuilds-internal-container-read-token)
+ PRIVATE_BUILD: $(PrivateBuild)
+
+ # Restore .NET tools (includes xharness)
+ - script: dotnet tool restore
+ displayName: 'Restore .NET Tools'
+
+ # Create AVD and boot Android Emulator
+ - ${{ if eq(parameters.Platform, 'android') }}:
+ # Free disk space on hosted agents (emulator needs ~7GB for userdata partition)
+ - script: |
+ echo "=== Disk space before cleanup ==="
+ df -h /home
+ echo "Removing unnecessary tools to free space..."
+ sudo rm -rf /usr/share/dotnet /usr/local/share/powershell /usr/local/share/chromium 2>/dev/null || true
+ sudo rm -rf /opt/hostedtoolcache/CodeQL /opt/hostedtoolcache/go /opt/hostedtoolcache/Python 2>/dev/null || true
+ sudo rm -rf /usr/share/swift 2>/dev/null || true
+ echo "=== Disk space after cleanup ==="
+ df -h /home
+ displayName: 'Free Disk Space for Emulator'
+
+ - script: |
+ export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}"
+ export PATH="$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/emulator:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$PATH"
+
+ echo "=== Creating AVD ==="
+ echo "no" | avdmanager create avd -n Emulator_30 -k "system-images;android-30;google_apis_playstore;x86_64" --device "Nexus 5X" --force
+
+ # Reduce userdata partition to fit on hosted agents (~4.2GB free)
+ AVD_CONFIG="$HOME/.android/avd/Emulator_30.avd/config.ini"
+ if [ -f "$AVD_CONFIG" ]; then
+ sed -i 's/disk.dataPartition.size=.*/disk.dataPartition.size=2048m/' "$AVD_CONFIG"
+ echo "Updated disk.dataPartition.size to 2048m"
+ fi
+
+ # Pre-authorize ADB keys (mirrors android.cake HandleVirtualDevice)
+ echo "=== Pre-authorizing ADB keys ==="
+ mkdir -p "$HOME/.android"
+ if [ ! -f "$HOME/.android/adbkey" ]; then
+ adb keygen "$HOME/.android/adbkey" 2>/dev/null || true
+ fi
+ ADB_KEY_PUB="$HOME/.android/adbkey.pub"
+ AVD_DIR="$HOME/.android/avd/Emulator_30.avd"
+ if [ -f "$ADB_KEY_PUB" ] && [ -d "$AVD_DIR" ]; then
+ cp "$ADB_KEY_PUB" "$AVD_DIR/adbkey.pub"
+ echo "ADB key pre-authorized for emulator"
+ fi
+
+ echo "=== Starting Emulator ==="
+ # Kill any stale adb server and restart
+ adb kill-server 2>/dev/null || true
+ sleep 1
+ adb start-server
+
+ # Retry loop: emulator sometimes fails to connect ADB on first launch
+ MAX_LAUNCH_ATTEMPTS=2
+ EMULATOR_PID=""
+ for LAUNCH_ATTEMPT in $(seq 1 $MAX_LAUNCH_ATTEMPTS); do
+ echo "--- Emulator launch attempt $LAUNCH_ATTEMPT of $MAX_LAUNCH_ATTEMPTS ---"
+
+ if [ $LAUNCH_ATTEMPT -gt 1 ]; then
+ echo "Cleaning up before retry..."
+ if [ -n "$EMULATOR_PID" ] && kill -0 "$EMULATOR_PID" 2>/dev/null; then
+ kill "$EMULATOR_PID" 2>/dev/null || true
+ sleep 2
+ kill -0 "$EMULATOR_PID" 2>/dev/null && kill -9 "$EMULATOR_PID" 2>/dev/null || true
+ fi
+ sleep 3
+ adb kill-server 2>/dev/null || true
+ sleep 2
+ adb start-server
+ sleep 2
+ fi
+
+ nohup emulator -avd Emulator_30 -gpu swiftshader_indirect -no-window -no-snapshot -no-audio -no-boot-anim -partition-size 2048 > /tmp/emulator.log 2>&1 &
+ EMULATOR_PID=$!
+ echo "Emulator PID: $EMULATOR_PID"
+
+ echo "Waiting for emulator device (adb wait-for-device, 120s timeout)..."
+ timeout 120 adb wait-for-device
+ if [ $? -eq 0 ]; then
+ echo "Device detected: $(adb devices -l | grep emulator)"
+ break
+ fi
+
+ echo "##vso[task.logissue type=warning]adb wait-for-device timed out (attempt $LAUNCH_ATTEMPT)"
+ adb devices -l
+ tail -30 /tmp/emulator.log
+
+ if [ $LAUNCH_ATTEMPT -eq $MAX_LAUNCH_ATTEMPTS ]; then
+ echo "##vso[task.logissue type=error]Emulator failed to connect after $MAX_LAUNCH_ATTEMPTS attempts"
+ exit 1
+ fi
+ done
+
+ timeout=300
+ waited=0
+ while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do
+ sleep 5
+ waited=$((waited + 5))
+ if [ $waited -ge $timeout ]; then
+ echo "##vso[task.logissue type=error]Emulator did not finish booting after ${timeout}s"
+ exit 1
+ fi
+ # At 90 seconds, restart ADB server to recover from auth issues (mirrors android.cake)
+ if [ $waited -eq 90 ]; then
+ echo "Boot taking longer than expected (90/${timeout}s). Restarting ADB server..."
+ adb kill-server 2>/dev/null || true
+ sleep 2
+ adb start-server
+ sleep 2
+ echo "ADB server restarted. Continuing to wait..."
+ fi
+ # Re-ensure ADB keys every 60s during boot (mirrors android.cake PrepareDevice)
+ if [ $((waited % 60)) -eq 0 ] && [ $waited -gt 0 ]; then
+ if [ -f "$ADB_KEY_PUB" ] && [ -d "$AVD_DIR" ]; then
+ cp "$ADB_KEY_PUB" "$AVD_DIR/adbkey.pub" 2>/dev/null || true
+ fi
+ fi
+ done
+ echo "Boot completed, waiting for package manager..."
+
+ timeout=120
+ waited=0
+ while ! adb shell pm list packages 2>/dev/null | grep -q "package:"; do
+ sleep 5
+ waited=$((waited + 5))
+ if [ $waited -ge $timeout ]; then
+ echo "##vso[task.logissue type=error]Package manager not ready after ${timeout}s"
+ exit 1
+ fi
+ done
+
+ DEVICE_ID=$(adb devices | grep "emulator.*device" | awk '{print $1}')
+ echo "✅ Emulator fully booted: $DEVICE_ID"
+
+ # Prepare emulator for CI use — keeps device responsive during idle period
+ echo "=== Preparing emulator for CI ==="
+ # Wait for device to stabilize after boot (transient offline state)
+ for i in $(seq 1 10); do
+ if adb -s $DEVICE_ID shell echo ok 2>/dev/null | grep -q ok; then
+ break
+ fi
+ echo "Device offline, retrying ($i/10)..."
+ sleep 3
+ done
+ # Disable all animations (reduces CPU load and flakiness)
+ adb -s $DEVICE_ID shell settings put global window_animation_scale 0.0
+ adb -s $DEVICE_ID shell settings put global transition_animation_scale 0.0
+ adb -s $DEVICE_ID shell settings put global animator_duration_scale 0.0
+ # Prevent screen from turning off (emulator simulates AC charging)
+ adb -s $DEVICE_ID shell settings put system screen_off_timeout 2147483647
+ adb -s $DEVICE_ID shell svc power stayon true
+ # Wake screen and dismiss any lock screen
+ adb -s $DEVICE_ID shell input keyevent 82
+ sleep 1
+ # Dismiss any "System UI has stopped" or crash dialogs
+ adb -s $DEVICE_ID shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true
+ # Clear logcat buffer so agent sees only fresh logs
+ adb -s $DEVICE_ID logcat -c 2>/dev/null || true
+ echo "Emulator preparation complete"
+
+ echo "##vso[task.setvariable variable=DEVICE_UDID]$DEVICE_ID"
+ echo "##vso[task.prependpath]$ANDROID_SDK_ROOT/platform-tools"
+ echo "##vso[task.prependpath]$ANDROID_SDK_ROOT/emulator"
+ displayName: 'Create AVD and Boot Android Emulator'
+ retryCountOnTaskFailure: 1
+ timeoutInMinutes: 15
+
+ # Install Node.js and Appium (same as ui-tests-steps.yml)
+ - task: UseNode@1
+ inputs:
+ version: "24.x"
+ displayName: 'Install Node.js'
+
+ - pwsh: |
+ $skipAppiumDoctor = if ($IsMacOS -or $IsLinux) { "true" } else { "false" }
+ dotnet build ./src/Provisioning/Provisioning.csproj -t:ProvisionAppium -p:SkipAppiumDoctor="$skipAppiumDoctor" -bl:"$(LogDirectory)/provision-appium.binlog"
+ displayName: 'Install Appium'
+ retryCountOnTaskFailure: 2
+ timeoutInMinutes: 10
+ env:
+ APPIUM_HOME: $(APPIUM_HOME)
+
+ - script: |
+ echo "Installing GitHub CLI..."
+ brew install gh
+ if ! gh --version; then
+ echo "##vso[task.logissue type=error]Failed to install GitHub CLI"
+ exit 1
+ fi
+ echo "GitHub CLI installed successfully"
+ displayName: 'Install GitHub CLI'
+
+ - script: |
+ echo "Authenticating with GitHub CLI..."
+ if [ -z "$(GH_CLI_TOKEN)" ]; then
+ echo "##vso[task.logissue type=error]GH_CLI_TOKEN is not set. Please configure the pipeline variable."
+ exit 1
+ fi
+ # Use GH_TOKEN env var to avoid scope validation issues with newer gh versions
+ export GH_TOKEN="$(GH_CLI_TOKEN)"
+ gh auth status
+ if [ $? -ne 0 ]; then
+ # Fallback: try direct login
+ echo "$(GH_CLI_TOKEN)" | gh auth login --with-token 2>/dev/null || true
+ if ! gh auth status; then
+ echo "##vso[task.logissue type=error]GitHub CLI authentication failed"
+ exit 1
+ fi
+ fi
+ echo "GitHub CLI authenticated successfully"
+ displayName: 'Authenticate GitHub CLI'
+ env:
+ GH_CLI_TOKEN: $(GH_CLI_TOKEN)
+
+ - script: |
+ echo "Installing GitHub Copilot CLI..."
+ npm install -g @github/copilot
+ # Ensure npm global bin is on PATH for subsequent steps (Linux UseNode installs to toolcache)
+ COPILOT_BIN_DIR=$(dirname "$(which copilot)")
+ echo "Copilot binary at: $COPILOT_BIN_DIR/copilot"
+ echo "##vso[task.prependpath]$COPILOT_BIN_DIR"
+ copilot --version || true
+ echo "Copilot CLI installed successfully"
+ displayName: 'Install GitHub Copilot CLI'
+
+
+ # Boot iOS Simulator (only for iOS platform)
+ # UI test baseline screenshots are captured on iPhone Xs - must use same device
+ - script: |
+ echo "=== Booting iOS Simulator ==="
+
+ # Find the latest stable iOS runtime (prefer 18.x, fallback to 17.x)
+ RUNTIME=$(xcrun simctl list runtimes available --json | jq -r '
+ [.runtimes[] | select(.name | test("iOS 18"))] | sort_by(.version) | last | .identifier // empty
+ ')
+ if [ -z "$RUNTIME" ]; then
+ RUNTIME=$(xcrun simctl list runtimes available --json | jq -r '
+ [.runtimes[] | select(.name | test("iOS 17"))] | sort_by(.version) | last | .identifier // empty
+ ')
+ fi
+ echo "Selected iOS runtime: $RUNTIME"
+
+ # Look for iPhone Xs (matches UI test baselines - required for snapshot tests)
+ UDID=$(xcrun simctl list devices available --json | jq -r --arg rt "$RUNTIME" '
+ .devices[$rt] // [] |
+ map(select(.name == "iPhone Xs")) |
+ .[0].udid // empty
+ ')
+
+ # If iPhone Xs doesn't exist, try iPhone 11 Pro (same 1125×2436 resolution)
+ if [ -z "$UDID" ]; then
+ UDID=$(xcrun simctl list devices available --json | jq -r --arg rt "$RUNTIME" '
+ .devices[$rt] // [] |
+ map(select(.name == "iPhone 11 Pro")) |
+ .[0].udid // empty
+ ')
+ if [ -n "$UDID" ]; then
+ echo "Found existing iPhone 11 Pro (same resolution as iPhone Xs): $UDID"
+ fi
+ else
+ echo "Found existing iPhone Xs: $UDID"
+ fi
+
+ # If neither exists, try to create them
+ if [ -z "$UDID" ]; then
+ echo "No matching device found - attempting to create one for runtime $RUNTIME..."
+
+ # Try iPhone Xs first
+ UDID=$(xcrun simctl create "iPhone Xs" com.apple.CoreSimulator.SimDeviceType.iPhone-Xs "$RUNTIME" 2>&1)
+ if [ $? -ne 0 ]; then
+ echo "iPhone Xs device type unavailable: $UDID"
+ # Try iPhone 11 Pro (same 1125×2436 resolution)
+ UDID=$(xcrun simctl create "iPhone 11 Pro" com.apple.CoreSimulator.SimDeviceType.iPhone-11-Pro "$RUNTIME" 2>&1)
+ if [ $? -ne 0 ]; then
+ echo "##vso[task.logissue type=warning]Failed to create iPhone 11 Pro: $UDID"
+ # Last resort: first available iPhone
+ UDID=$(xcrun simctl list devices available --json | jq -r '
+ .devices | to_entries |
+ map(.value) | flatten |
+ map(select(.name | test("iPhone"))) |
+ .[0].udid
+ ')
+ else
+ echo "Created iPhone 11 Pro simulator: $UDID"
+ fi
+ else
+ echo "Created iPhone Xs simulator: $UDID"
+ fi
+ fi
+
+ if [ -z "$UDID" ]; then
+ echo "##vso[task.logissue type=error]No iOS simulator found"
+ exit 1
+ fi
+
+ # Shutdown any other booted simulators to avoid Appium connecting to wrong device
+ xcrun simctl list devices booted --json | jq -r '
+ .devices | to_entries | map(.value) | flatten |
+ map(select(.state == "Booted" and .udid != "'"$UDID"'")) |
+ .[].udid
+ ' | while read OTHER_UDID; do
+ echo "Shutting down other simulator: $OTHER_UDID"
+ xcrun simctl shutdown "$OTHER_UDID" 2>/dev/null || true
+ done
+
+ echo "Booting simulator: $UDID"
+ xcrun simctl boot "$UDID" 2>/dev/null || echo "Simulator may already be booted"
+ sleep 10
+
+ echo "Booted simulators:"
+ xcrun simctl list devices booted
+
+ echo "##vso[task.setvariable variable=DEVICE_UDID]$UDID"
+ echo "iOS Simulator UDID: $UDID"
+ displayName: 'Boot iOS Simulator'
+ condition: eq('${{ parameters.Platform }}', 'ios')
+ timeoutInMinutes: 5
+
+ # Warm up the emulator right before the agent runs.
+ # The emulator may have been idle for 15-30 min while Appium/Node/CLI were installed.
+ # Without this, SystemUI can ANR when the agent first touches it.
+ - script: |
+ set -e
+ DEVICE_ID="$(DEVICE_UDID)"
+ if [ -z "$DEVICE_ID" ]; then
+ echo "No DEVICE_UDID set — skipping warmup"
+ exit 0
+ fi
+
+ echo "=== Emulator warmup before agent ==="
+ # Verify device is still connected
+ if ! adb -s "$DEVICE_ID" shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; then
+ echo "Device not responding. Restarting ADB server..."
+ adb kill-server 2>/dev/null || true
+ sleep 2
+ adb start-server
+ sleep 2
+ timeout 60 adb wait-for-device
+ fi
+
+ # Dismiss ANR dialogs and wake screen — run twice for reliability
+ for PASS in 1 2; do
+ echo "--- Warmup pass $PASS ---"
+ adb -s "$DEVICE_ID" shell input keyevent KEYCODE_WAKEUP 2>/dev/null || true
+ adb -s "$DEVICE_ID" shell input keyevent KEYCODE_MENU 2>/dev/null || true
+ sleep 1
+
+ # Dismiss system dialogs (ANR, crash, etc.)
+ adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true
+ adb -s "$DEVICE_ID" shell input keyevent KEYCODE_ENTER 2>/dev/null || true
+ adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true
+ sleep 1
+ done
+
+ # Check for lingering ANR in window state
+ if adb -s "$DEVICE_ID" shell dumpsys window 2>/dev/null | grep -qi "Application Not Responding\|ANR"; then
+ echo "⚠️ ANR dialog still present — force-dismissing with HOME + BACK"
+ adb -s "$DEVICE_ID" shell input keyevent KEYCODE_HOME 2>/dev/null || true
+ sleep 2
+ adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true
+ adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true
+ sleep 1
+ fi
+
+ # Open and close Settings to exercise the system and confirm responsiveness
+ adb -s "$DEVICE_ID" shell am start -a android.settings.SETTINGS 2>/dev/null || true
+ sleep 3
+ adb -s "$DEVICE_ID" shell am force-stop com.android.settings 2>/dev/null || true
+
+ # Final dialog sweep
+ adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true
+ adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true
+
+ # Clear logcat so agent gets clean logs
+ adb -s "$DEVICE_ID" logcat -c 2>/dev/null || true
+
+ echo "✅ Emulator warmed up and responsive"
+ displayName: 'Warm Up Android Emulator'
+ condition: and(succeeded(), eq('${{ parameters.Platform }}', 'android'))
+ timeoutInMinutes: 3
+
+ - script: |
+ echo "Running Copilot PR Reviewer Agent via Review-PR.ps1..."
+ echo "Reviewing PR #${{ parameters.PRNumber }}..."
+
+ # Ensure copilot CLI is accessible to pwsh subprocess.
+ # npm global install on Linux goes to UseNode@1 toolcache path which may not
+ # be on PATH inside pwsh even when exported from bash. Create a symlink in
+ # /usr/local/bin which is universally on PATH for all shells.
+ COPILOT_PATH=$(which copilot 2>/dev/null || find /opt/hostedtoolcache/node -name copilot -type f 2>/dev/null | head -1)
+ if [ -n "$COPILOT_PATH" ] && [ ! -f /usr/local/bin/copilot ]; then
+ sudo ln -sf "$COPILOT_PATH" /usr/local/bin/copilot
+ echo "Symlinked copilot to /usr/local/bin/copilot"
+ fi
+ echo "copilot location: $(which copilot 2>/dev/null || echo 'not found')"
+ # Verify pwsh can find it
+ pwsh -NoProfile -c 'Write-Host "pwsh sees copilot at: $(Get-Command copilot -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)"'
+
+ # Configure git identity (required for merge operations on self-hosted agents)
+ git config user.email "copilot-ci@microsoft.com"
+ git config user.name "Copilot CI"
+ echo "Git identity configured"
+
+ # Create Directory.Build.Override.props to skip Xcode version check
+ # AcesShared agents may have a newer Xcode than the .NET iOS SDK expects
+ cp Directory.Build.Override.props.in Directory.Build.Override.props
+ # Insert ValidateXcodeVersion before closing tag
+ # GNU sed (Linux) uses -i without suffix; BSD sed (macOS) uses -i ''
+ if [[ "$(uname)" == "Linux" ]]; then
+ sed -i 's|| false\n|' Directory.Build.Override.props
+ else
+ sed -i '' 's|| false\n|' Directory.Build.Override.props
+ fi
+
+ # Create artifacts directory for Copilot outputs
+ mkdir -p $(Build.ArtifactStagingDirectory)/copilot-logs
+
+ # Invoke the PR reviewer using our PowerShell script
+ # The script will merge the PR into the current branch
+ # -PostSummaryComment and -RunFinalize handle posting comments
+ set +e
+ pwsh -NoProfile .github/scripts/Review-PR.ps1 -PRNumber ${{ parameters.PRNumber }} -Platform ${{ parameters.Platform }} -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md"
+ COPILOT_EXIT_CODE=$?
+ set -e
+
+ echo "Review-PR.ps1 exit code: $COPILOT_EXIT_CODE"
+
+ # Terminate any orphaned copilot CLI processes that could hold this step's
+ # stdout fd open and prevent the bash step from exiting.
+ # Only target processes whose command line includes the copilot CLI path.
+ echo "Cleaning up orphaned copilot processes..."
+ SELF_PID=$$
+ for proc in $(pgrep -f "[c]opilot" 2>/dev/null || true); do
+ if [ -n "$proc" ] && [ "$proc" != "$SELF_PID" ]; then
+ PROC_CMD=$(ps -p "$proc" -o args= 2>/dev/null || true)
+ if echo "$PROC_CMD" | grep -q "copilot"; then
+ echo " Stopping copilot process $proc: $PROC_CMD"
+ kill "$proc" 2>/dev/null || true
+ fi
+ fi
+ done
+
+ # Copy any Copilot session files
+ if [ -d "$HOME/.copilot" ]; then
+ echo "Copying Copilot session state..."
+ cp -r "$HOME/.copilot" $(Build.ArtifactStagingDirectory)/copilot-logs/copilot-session-state || true
+ fi
+
+ # Copy CustomAgentLogsTmp if it exists
+ if [ -d "CustomAgentLogsTmp" ]; then
+ echo "Copying CustomAgentLogsTmp..."
+ cp -r CustomAgentLogsTmp $(Build.ArtifactStagingDirectory)/copilot-logs/ || true
+ fi
+
+ # Copy any Review_Feedback files
+ find . -name "Review_Feedback_*.md" -type f -exec cp {} $(Build.ArtifactStagingDirectory)/copilot-logs/ \; 2>/dev/null || true
+
+ # Copy any .github/agent-pr-session files
+ if [ -d ".github/agent-pr-session" ]; then
+ echo "Copying agent-pr-session..."
+ cp -r .github/agent-pr-session $(Build.ArtifactStagingDirectory)/copilot-logs/ || true
+ fi
+
+ # Check for failure indicators in output
+ if [ $COPILOT_EXIT_CODE -ne 0 ]; then
+ echo "##vso[task.logissue type=error]Review-PR.ps1 exited with code $COPILOT_EXIT_CODE"
+ # Don't exit yet - let artifacts be published first
+ echo "##vso[task.setvariable variable=CopilotFailed]true"
+ fi
+
+ # Check output for common failure patterns
+ if grep -qi "error\|failed\|exception" $(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md 2>/dev/null; then
+ if grep -qi "simulator.*not\|emulator.*not\|workload.*not\|sdk.*not found" $(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md 2>/dev/null; then
+ echo "##vso[task.logissue type=warning]Copilot encountered environment issues. Check artifacts for details."
+ fi
+ fi
+
+ echo "Review output saved to $(Build.ArtifactStagingDirectory)/copilot-logs/"
+ displayName: 'Run PR Reviewer Agent'
+ env:
+ COPILOT_GITHUB_TOKEN: $(COPILOT_TOKEN)
+ GH_TOKEN: $(GH_COMMENT_TOKEN)
+ DEVICE_UDID: $(DEVICE_UDID)
+
+ # Publish Copilot logs and session artifacts
+ - task: PublishPipelineArtifact@1
+ displayName: 'Publish Copilot Logs'
+ inputs:
+ targetPath: '$(Build.ArtifactStagingDirectory)/copilot-logs'
+ artifact: 'CopilotLogs'
+ publishLocation: 'pipeline'
+ condition: succeededOrFailed()
+
+ # Publish build logs if they exist
+ - task: PublishPipelineArtifact@1
+ displayName: 'Publish Build Logs'
+ inputs:
+ targetPath: '$(LogDirectory)'
+ artifact: 'BuildLogs'
+ publishLocation: 'pipeline'
+ condition: and(succeededOrFailed(), ne(variables['LogDirectory'], ''))
+
+ # Fail the pipeline if Copilot failed
+ - script: |
+ if [ "$(CopilotFailed)" = "true" ]; then
+ echo "##vso[task.logissue type=error]Copilot PR review failed. Check CopilotLogs artifact for details."
+ exit 1
+ fi
+ displayName: 'Check Copilot Result'
+ condition: succeededOrFailed()
diff --git a/eng/pipelines/ci-uitests.yml b/eng/pipelines/ci-uitests.yml
index 3588b03db746..7e16e1733d75 100644
--- a/eng/pipelines/ci-uitests.yml
+++ b/eng/pipelines/ci-uitests.yml
@@ -167,12 +167,12 @@ stages:
# BuildNativeAOT is false by default, but true in devdiv environment
BuildNativeAOT: ${{ or(parameters.BuildNativeAOT, and(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['System.TeamProject'], 'devdiv'))) }}
RunNativeAOT: ${{ parameters.RunNativeAOT }}
- ${{ if or(parameters.BuildEverything, and(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['System.TeamProject'], 'devdiv'))) }}:
+ ${{ if or(parameters.BuildEverything, ne(variables['Build.Reason'], 'PullRequest')) }}:
androidApiLevels: [ 30 ]
- iosVersions: [ '18.4' ]
+ iosVersions: [ '18.5', 'latest' ]
${{ else }}:
androidApiLevels: [ 30 ]
- iosVersions: [ '18.4' ]
+ iosVersions: [ 'latest' ]
projects:
- name: controls
desc: Controls
diff --git a/eng/pipelines/ci.yml b/eng/pipelines/ci.yml
index 7787a888117b..218419358df5 100644
--- a/eng/pipelines/ci.yml
+++ b/eng/pipelines/ci.yml
@@ -290,7 +290,25 @@ stages:
timeout: 120
testCategory: MultiProject
- # TODO: macOSTemplates and AOT template categories
+ # TODO: macOSTemplates category
+
+ - name: win_aot_tests
+ ${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}:
+ pool: ${{ parameters.WindowsPool.public }}
+ runAsPublic: true
+ ${{ else }}:
+ pool: ${{ parameters.WindowsPool.internal }}
+ runAsPublic: false
+ timeout: 120
+ testCategory: AOT
+ - name: mac_aot_tests
+ ${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}:
+ pool: ${{ parameters.MacOSPool.public }}
+ ${{ else }}:
+ pool: ${{ parameters.MacOSPool.internal }}
+ timeout: 240
+ testCategory: AOT
+
- name: mac_runandroid_tests
${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}:
pool: ${{ parameters.AndroidPoolLinux }}
diff --git a/eng/pipelines/common/provision.yml b/eng/pipelines/common/provision.yml
index 9dbe7125e1e7..c829f9638e7a 100644
--- a/eng/pipelines/common/provision.yml
+++ b/eng/pipelines/common/provision.yml
@@ -32,6 +32,7 @@ parameters:
expiryInHours: 1
base64Encode: false
skipInternalFeeds: true
+ skipCertificates: false
steps:
@@ -168,9 +169,19 @@ steps:
done
if [[ -z "$XCODE_PATH" ]]; then
- echo "ERROR: No suitable Xcode version found for requested version ${ORIGINAL_VERSION}"
+ echo "WARNING: No exact match for requested Xcode version ${ORIGINAL_VERSION}"
echo "Tried: ${VERSIONS_TO_TRY[*]}"
- exit 1
+ echo "Falling back to latest available Xcode on this agent..."
+ # Find the latest Xcode by sorting version numbers
+ LATEST_XCODE=$(ls -1d /Applications/Xcode_*.app 2>/dev/null | sed 's|/Applications/Xcode_||;s|\.app||' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
+ if [[ -n "$LATEST_XCODE" ]]; then
+ XCODE_VERSION="$LATEST_XCODE"
+ XCODE_PATH="/Applications/Xcode_${LATEST_XCODE}.app"
+ echo "Using latest available Xcode: ${XCODE_VERSION} at ${XCODE_PATH}"
+ else
+ echo "ERROR: No Xcode installations found in /Applications"
+ exit 1
+ fi
fi
sudo xcode-select -s "$XCODE_PATH"
diff --git a/eng/pipelines/common/ui-tests.yml b/eng/pipelines/common/ui-tests.yml
index 5b0798ac2974..cd06dcfbcc75 100644
--- a/eng/pipelines/common/ui-tests.yml
+++ b/eng/pipelines/common/ui-tests.yml
@@ -9,6 +9,7 @@ parameters:
androidApiLevelsExtended: [ 36 ] # API 36 for Material3 tests with Pixel 3 XL
iosVersions: [ 'latest' ]
provisionatorChannel: 'latest'
+ defaultiOSVersion: '26.0'
timeoutInMinutes: 180
skipProvisioning: true
BuildNativeAOT: false # Parameter to control whether NativeAOT artifacts should be built
@@ -335,7 +336,7 @@ stages:
parameters:
platform: ios
${{ if eq(version, 'latest') }}:
- version: 18.5
+ version: ${{ parameters.defaultiOSVersion }}
${{ if ne(version, 'latest') }}:
version: ${{ version }}
path: ${{ project.ios }}
@@ -377,7 +378,7 @@ stages:
parameters:
platform: ios
${{ if eq(version, 'latest') }}:
- version: 18.5
+ version: ${{ parameters.defaultiOSVersion }}
${{ if ne(version, 'latest') }}:
version: ${{ version }}
path: ${{ project.ios }}
@@ -420,7 +421,7 @@ stages:
parameters:
platform: ios
${{ if eq(version, 'latest') }}:
- version: 18.5
+ version: ${{ parameters.defaultiOSVersion }}
${{ if ne(version, 'latest') }}:
version: ${{ version }}
path: ${{ project.ios }}
@@ -469,7 +470,7 @@ stages:
parameters:
platform: ios
${{ if eq(version, 'latest') }}:
- version: 18.5
+ version: ${{ parameters.defaultiOSVersion }}
${{ if ne(version, 'latest') }}:
version: ${{ version }}
path: ${{ project.ios }}
diff --git a/eng/pipelines/common/variables.yml b/eng/pipelines/common/variables.yml
index 62f6b0af0fcd..012613e07189 100644
--- a/eng/pipelines/common/variables.yml
+++ b/eng/pipelines/common/variables.yml
@@ -54,14 +54,7 @@ variables:
- group: MAUI # This is the main MAUI variable group that contains secrets for the apple certificate
-# Variable groups required for all builds
-- ${{ if and(ne(variables['Build.DefinitionName'], 'maui-pr'), ne(variables['Build.DefinitionName'], 'dotnet-maui'), ne(variables['Build.DefinitionName'], 'maui-pr-devicetests'), ne(variables['Build.DefinitionName'], 'maui-pr-uitests')) }}:
- - group: maui-provisionator # This is just needed for the provisionator
-
-
-- ${{ if or(eq(variables['System.TeamProject'], 'DevDiv'), eq(variables['Build.DefinitionName'], 'dotnet-maui'), eq(variables['Build.DefinitionName'], 'dotnet-maui-build')) }}:
- - name: internalProvisioning
- value: true
+- ${{ if or(eq(variables['Build.DefinitionName'], 'dotnet-maui'), eq(variables['Build.DefinitionName'], 'dotnet-maui-build')) }}:
- ${{ if notin(variables['Build.Reason'], 'PullRequest') }}:
- name: PrivateBuild
value: false
@@ -71,13 +64,11 @@ variables:
value: true
- name: _SignType
value: real
-
- - group: AzureDevOps-Artifact-Feeds-Pats
-
-- ${{ if eq(variables['Build.DefinitionName'], 'dotnet-maui') }}:
- - ${{ if notin(variables['Build.Reason'], 'PullRequest') }}:
- # Publish-Build-Assets provides: MaestroAccessToken, BotAccount-dotnet-maestro-bot-PAT
- # DotNet-HelixApi-Access provides: HelixApiAccessToken
- - group: Publish-Build-Assets
- group: DotNet-HelixApi-Access
- group: SDL_Settings
+ - group: AzureDevOps-Artifact-Feeds-Pats
+ - ${{ if eq(variables['Build.DefinitionName'], 'dotnet-maui') }}:
+ - group: Publish-Build-Assets # This variable group contains secrets to publis to BAR
+
+
+
diff --git a/eng/pipelines/device-tests.yml b/eng/pipelines/device-tests.yml
index c290dbee24e4..340c09e61451 100644
--- a/eng/pipelines/device-tests.yml
+++ b/eng/pipelines/device-tests.yml
@@ -219,3 +219,15 @@ stages:
ios: $(System.DefaultWorkingDirectory)/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj
catalyst: $(System.DefaultWorkingDirectory)/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj
windows: $(System.DefaultWorkingDirectory)/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj
+ - name: essentialsai
+ desc: Essentials.AI
+ androidApiLevelsExclude: [ 25, 27 ]
+ androidApiLevelsCoreClrExclude: [ 27, 25, 23]
+ androidConfiguration: 'Release'
+ iOSConfiguration: 'Debug'
+ windowsConfiguration: 'Debug'
+ windowsPackageId: 'com.microsoft.maui.ai.devicetests'
+ android: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj
+ ios: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj
+ catalyst: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj
+ windows: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj
diff --git a/eng/scripts/get-maui-pr.ps1 b/eng/scripts/get-maui-pr.ps1
index e20f81a86828..eeb19414d1aa 100644
--- a/eng/scripts/get-maui-pr.ps1
+++ b/eng/scripts/get-maui-pr.ps1
@@ -146,12 +146,33 @@ function Get-PullRequestInfo {
}
}
-# Get build information from GitHub Checks API
+# Check if a build is currently in progress for this PR via Azure DevOps API
+function Test-BuildInProgress {
+ param([int]$PrNumber)
+
+ try {
+ $buildsUrl = "https://dev.azure.com/$AzureDevOpsOrg/$AzureDevOpsProject/_apis/build/builds?api-version=7.1&branchName=refs/pull/$PrNumber/merge&`$top=10"
+ $response = Invoke-RestMethod -Uri $buildsUrl -Headers @{ "User-Agent" = "MAUI-PR-Script" } -TimeoutSec 30
+
+ foreach ($build in $response.value) {
+ if ($build.definition.name -eq "maui-pr" -and $build.status -in @("inProgress", "notStarted", "postponed")) {
+ return $true
+ }
+ }
+ return $false
+ }
+ catch {
+ return $false
+ }
+}
+
+# Get build information from GitHub Checks API, with AzDO fallback
function Get-BuildInfo {
- param([string]$SHA)
+ param([string]$SHA, [int]$PrNumber)
Write-Info "Looking for build artifacts for commit $($SHA.Substring(0, 7))..."
+ # Strategy 1: Try GitHub Checks API
try {
$checksUrl = "https://api.github.com/repos/$GitHubRepo/commits/$SHA/check-runs"
$response = Invoke-RestMethod -Uri $checksUrl -Headers ($GitHubHeaders + @{
@@ -163,34 +184,87 @@ function Get-BuildInfo {
$_.name -like "maui-pr*" -and $_.name -notlike "*uitests*" -and $_.status -eq "completed" -and $_.details_url -match 'buildId='
} | Select-Object -First 1
- if (-not $buildCheck) {
- throw "No completed build found for this PR. The build may still be in progress or may have failed."
- }
-
- if ($buildCheck.conclusion -ne "success") {
- Write-Warn "Build completed with status: $($buildCheck.conclusion)"
- if (-not $Yes) {
- $continue = Read-Host "Do you want to continue anyway? (y/N)"
- if ($continue -ne "y" -and $continue -ne "Y") {
- throw "Build was not successful. Aborting."
+ if ($buildCheck) {
+ if ($buildCheck.conclusion -ne "success") {
+ Write-Warn "Build completed with status: $($buildCheck.conclusion)"
+ if (-not $Yes) {
+ $continue = Read-Host "Do you want to continue anyway? (y/N)"
+ if ($continue -ne "y" -and $continue -ne "Y") {
+ throw "Build was not successful. Aborting."
+ }
+ }
+ }
+
+ # Extract build ID from details URL
+ if ($buildCheck.details_url -match 'buildId=(\d+)') {
+ Write-Success "Found build ID: $($Matches[1]) (via GitHub Checks)"
+ return @{
+ BuildId = $Matches[1]
+ Status = $buildCheck.conclusion
+ Url = $buildCheck.details_url
}
}
}
+ }
+ catch {
+ Write-Info "GitHub Checks API lookup failed, trying Azure DevOps directly..."
+ }
+
+ # Strategy 2: Query Azure DevOps directly (handles merge commits not reported to GitHub)
+ Write-Info "Searching Azure DevOps directly for PR #$PrNumber builds..."
+ try {
+ $buildsUrl = "https://dev.azure.com/$AzureDevOpsOrg/$AzureDevOpsProject/_apis/build/builds?api-version=7.1&branchName=refs/pull/$PrNumber/merge&`$top=10"
+ $response = Invoke-RestMethod -Uri $buildsUrl -Headers @{ "User-Agent" = "MAUI-PR-Script" } -TimeoutSec 30
+
+ $completedBuild = $response.value | Where-Object {
+ $_.definition.name -eq "maui-pr" -and $_.status -eq "completed"
+ } | Select-Object -First 1
- # Extract build ID from details URL
- if ($buildCheck.details_url -match 'buildId=(\d+)') {
+ if ($completedBuild) {
+ # Validate build ID is numeric
+ if ("$($completedBuild.id)" -notmatch '^\d+$') {
+ throw "Invalid build ID received from Azure DevOps API"
+ }
+
+ # Check if a newer build is in progress (user may have pushed a new commit)
+ $inProgressBuild = $response.value | Where-Object {
+ $_.definition.name -eq "maui-pr" -and $_.status -in @("inProgress", "notStarted", "postponed")
+ } | Select-Object -First 1
+ if ($inProgressBuild) {
+ Write-Warn "A newer build is currently in progress. The available artifacts may be from a previous commit."
+ Write-Warn "If you just pushed changes, wait for the new build to complete."
+ }
+
+ if ($completedBuild.result -ne "succeeded") {
+ Write-Warn "Build completed with result: $($completedBuild.result)"
+ if (-not $Yes) {
+ $continue = Read-Host "Do you want to continue anyway? (y/N)"
+ if ($continue -ne "y" -and $continue -ne "Y") {
+ throw "Build was not successful. Aborting."
+ }
+ }
+ }
+
+ $buildUrl = "https://dev.azure.com/$AzureDevOpsOrg/$AzureDevOpsProject/_build/results?buildId=$($completedBuild.id)"
+ Write-Success "Found build ID: $($completedBuild.id) (via Azure DevOps)"
return @{
- BuildId = $Matches[1]
- Status = $buildCheck.conclusion
- Url = $buildCheck.details_url
+ BuildId = "$($completedBuild.id)"
+ Status = $completedBuild.result
+ Url = $buildUrl
}
}
-
- throw "Could not extract build ID from check run details."
}
catch {
- throw "Failed to get build information: $_"
+ Write-Info "Azure DevOps direct lookup also failed."
}
+
+ # No build found - check if one is in progress
+ $buildInProgress = Test-BuildInProgress -PrNumber $PrNumber
+ if ($buildInProgress) {
+ throw "No completed build found, but a build is currently in progress for PR #$PrNumber. Please wait for it to complete and try again. Check status: https://github.com/dotnet/maui/pull/$PrNumber"
+ }
+
+ throw "No completed build found for PR #$PrNumber. The PR may not have triggered CI builds yet (draft PRs don't auto-trigger builds), or the build may have failed. Check: https://github.com/dotnet/maui/pull/$PrNumber"
}
# Get artifacts from Azure DevOps
@@ -468,7 +542,7 @@ try {
Write-Info "Current target framework: .NET $targetNetVersion.0"
Write-Step "Finding build artifacts"
- $buildInfo = Get-BuildInfo -SHA $prInfo.SHA
+ $buildInfo = Get-BuildInfo -SHA $prInfo.SHA -PrNumber $PrNumber
Write-Step "Downloading artifacts"
$downloadUrl = Get-BuildArtifacts -BuildId $buildInfo.BuildId
diff --git a/eng/scripts/get-maui-pr.sh b/eng/scripts/get-maui-pr.sh
index 9541a0556644..8a37b98c6006 100644
--- a/eng/scripts/get-maui-pr.sh
+++ b/eng/scripts/get-maui-pr.sh
@@ -178,12 +178,32 @@ get_pr_info() {
echo "$pr_json"
}
-# Get build information from GitHub Checks API
+# Check if a build is currently in progress for this PR via Azure DevOps API
+check_build_in_progress() {
+ local pr_num="$1"
+
+ local builds_url="https://dev.azure.com/$AZURE_DEVOPS_ORG/$AZURE_DEVOPS_PROJECT/_apis/build/builds?api-version=7.1&branchName=refs/pull/$pr_num/merge&\$top=10"
+ local builds_json
+ builds_json=$(curl -s -H "User-Agent: MAUI-PR-Script" "$builds_url" 2>/dev/null) || return 1
+
+ # Check if any maui-pr build is in progress
+ local in_progress
+ in_progress=$(echo "$builds_json" | jq -r '[.value[] | select(.definition.name == "maui-pr" and (.status == "inProgress" or .status == "notStarted" or .status == "postponed"))] | length' 2>/dev/null || echo "0")
+
+ if [ "$in_progress" != "0" ] && [ -n "$in_progress" ]; then
+ return 0 # true - build is in progress
+ fi
+ return 1 # false - no build in progress
+}
+
+# Get build information from GitHub Checks API, with AzDO fallback
get_build_info() {
local sha="$1"
+ local pr_num="$2"
info "Looking for build artifacts for commit ${sha:0:7}..."
+ # Strategy 1: Try GitHub Checks API
local checks_url="https://api.github.com/repos/$GITHUB_REPO/commits/$sha/check-runs"
local checks_json
checks_json=$(curl -s -H "User-Agent: MAUI-PR-Script" -H "Accept: application/vnd.github.v3+json" ${GITHUB_AUTH_HEADER:+-H "$GITHUB_AUTH_HEADER"} "$checks_url")
@@ -191,37 +211,91 @@ get_build_info() {
# Find the main MAUI build check (not uitests)
local build_check=$(echo "$checks_json" | jq -r '.check_runs[] | select((.name | startswith("maui-pr")) and (.name | contains("uitests") | not) and .status == "completed" and (.details_url | contains("buildId="))) | @json' | head -n 1)
- if [ -z "$build_check" ] || [ "$build_check" == "null" ]; then
- error "No completed build found for this PR"
- info "The build may still be in progress or may have failed."
- exit 1
+ if [ -n "$build_check" ] && [ "$build_check" != "null" ]; then
+ local conclusion=$(echo "$build_check" | jq -r '.conclusion')
+ if [ "$conclusion" != "success" ]; then
+ warning "Build completed with status: $conclusion"
+ if [ "$YES_FLAG" = true ]; then
+ info "Auto-accepting non-successful build (-y flag)"
+ else
+ read -p "Do you want to continue anyway? (y/N) " -n 1 -r
+ echo >&2
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ error "Build was not successful. Aborting."
+ exit 1
+ fi
+ fi
+ fi
+
+ # Extract build ID from details URL
+ local details_url=$(echo "$build_check" | jq -r '.details_url')
+ if [[ "$details_url" =~ buildId=([0-9]+) ]]; then
+ local build_id="${BASH_REMATCH[1]}"
+ success "Found build ID: $build_id (via GitHub Checks)"
+ echo "$build_id"
+ return 0
+ fi
fi
- local conclusion=$(echo "$build_check" | jq -r '.conclusion')
- if [ "$conclusion" != "success" ]; then
- warning "Build completed with status: $conclusion"
- if [ "$YES_FLAG" = true ]; then
- info "Auto-accepting non-successful build (-y flag)"
- else
- read -p "Do you want to continue anyway? (y/N) " -n 1 -r
- echo >&2
- if [[ ! $REPLY =~ ^[Yy]$ ]]; then
- error "Build was not successful. Aborting."
+ # Strategy 2: Query Azure DevOps directly (handles merge commits not reported to GitHub)
+ info "Searching Azure DevOps directly for PR #$pr_num builds..."
+ local builds_url="https://dev.azure.com/$AZURE_DEVOPS_ORG/$AZURE_DEVOPS_PROJECT/_apis/build/builds?api-version=7.1&branchName=refs/pull/$pr_num/merge&\$top=10"
+ local builds_json
+ builds_json=$(curl -s -H "User-Agent: MAUI-PR-Script" "$builds_url" 2>/dev/null)
+
+ if [ -n "$builds_json" ]; then
+ local completed_build
+ completed_build=$(echo "$builds_json" | jq -r '[.value[] | select(.definition.name == "maui-pr" and .status == "completed")] | first | @json' 2>/dev/null || echo "")
+
+ if [ -n "$completed_build" ] && [ "$completed_build" != "null" ]; then
+ local azdo_build_id=$(echo "$completed_build" | jq -r '.id')
+ local azdo_result=$(echo "$completed_build" | jq -r '.result')
+
+ # Validate build ID is numeric
+ if ! [[ "$azdo_build_id" =~ ^[0-9]+$ ]]; then
+ error "Invalid build ID received from Azure DevOps API"
exit 1
fi
+
+ # Check if a newer build is in progress (user may have pushed a new commit)
+ local in_progress_count
+ in_progress_count=$(echo "$builds_json" | jq -r '[.value[] | select(.definition.name == "maui-pr" and (.status == "inProgress" or .status == "notStarted" or .status == "postponed"))] | length' 2>/dev/null || echo "0")
+ if [ "$in_progress_count" != "0" ] && [ -n "$in_progress_count" ]; then
+ warning "A newer build is currently in progress. The available artifacts may be from a previous commit."
+ warning "If you just pushed changes, wait for the new build to complete."
+ fi
+
+ if [ "$azdo_result" != "succeeded" ]; then
+ warning "Build completed with result: $azdo_result"
+ if [ "$YES_FLAG" = true ]; then
+ info "Auto-accepting non-successful build (-y flag)"
+ else
+ read -p "Do you want to continue anyway? (y/N) " -n 1 -r
+ echo >&2
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ error "Build was not successful. Aborting."
+ exit 1
+ fi
+ fi
+ fi
+
+ success "Found build ID: $azdo_build_id (via Azure DevOps)"
+ echo "$azdo_build_id"
+ return 0
fi
fi
- # Extract build ID from details URL
- local details_url=$(echo "$build_check" | jq -r '.details_url')
- if [[ "$details_url" =~ buildId=([0-9]+) ]]; then
- local build_id="${BASH_REMATCH[1]}"
- success "Found build ID: $build_id"
- echo "$build_id"
- return 0
+ # No build found - check if one is in progress
+ if check_build_in_progress "$pr_num"; then
+ error "No completed build found, but a build is currently in progress for PR #$pr_num"
+ info "Please wait for it to complete and try again."
+ info "Check status: https://github.com/dotnet/maui/pull/$pr_num"
+ exit 1
fi
- error "Could not extract build ID from check run details"
+ error "No completed build found for PR #$pr_num"
+ info "The PR may not have triggered CI builds yet (draft PRs don't auto-trigger builds), or the build may have failed."
+ info "Check: https://github.com/dotnet/maui/pull/$pr_num"
exit 1
}
@@ -450,6 +524,13 @@ main() {
fi
pr_number="${positional_args[0]}" # Global for error handler
+
+ # Validate PR number is numeric
+ if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
+ error "PR number must be a valid number, got: $pr_number"
+ exit 1
+ fi
+
local project_path_arg="${positional_args[1]:-}"
# Check dependencies
@@ -497,7 +578,7 @@ EOF
step "Finding build artifacts"
local build_id
- build_id=$(get_build_info "$pr_sha")
+ build_id=$(get_build_info "$pr_sha" "$pr_number")
step "Downloading artifacts"
local download_url
diff --git a/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs
index b8522927d006..8f20bca343c0 100644
--- a/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs
+++ b/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs
@@ -1,4 +1,3 @@
-using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
@@ -9,30 +8,17 @@ namespace Maui.Controls.Sample.AI;
///
/// Agent 1: Travel Planner - Parses natural language to extract intent.
/// No tools - just NLP to extract destinationName, dayCount, language.
-/// Extends ChatProtocolExecutor to support the chat protocol for workflow-as-agent.
///
-internal sealed class TravelPlannerExecutor(AIAgent agent, JsonSerializerOptions jsonOptions, ILogger logger)
- : ChatProtocolExecutor("TravelPlannerExecutor")
+internal sealed class TravelPlannerExecutor(AIAgent agent, ILogger logger)
+ : ChatProtocolExecutor("TravelPlannerExecutor", new ChatProtocolExecutorOptions { AutoSendTurnToken = false })
{
- public const string Instructions = """
- You are a simple text parser.
-
- Extract ONLY these 3 values from the user's request:
- 1. destinationName: The place/location name mentioned (extract it exactly as written)
- 2. dayCount: The number of days mentioned (default: 3 if not specified)
- 3. language: The language mentioned for the output (default: English if not specified)
-
- Rules:
- 1. ALWAYS extract the raw values.
- 2. NEVER make up values or interpret the user's intent.
-
- Examples:
- - "5-day trip to Maui in French" → destinationName: "Maui", dayCount: 5, language: "French"
- - "Visit the Great Wall" → destinationName: "Great Wall", dayCount: 3, language: "English"
- - "Itinerary for Tokyo" → destinationName: "Tokyo", dayCount: 3, language: "English"
- - "Give me a Maui itinerary" → destinationName: "Maui", dayCount: 3, language: "English"
- - "Plan a 7 day Japan trip in Spanish" → destinationName: "Japan", dayCount: 7, language: "Spanish"
- """;
+ ///
+ /// Declares TravelPlanResult as a sent message type so the edge router can map it to downstream executors.
+ /// Without this, ChatProtocolExecutor only declares List<ChatMessage> and TurnToken, causing
+ /// TravelPlanResult to be silently dropped with DroppedTypeMismatch.
+ ///
+ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
+ => base.ConfigureProtocol(protocolBuilder).SendsMessage();
protected override async ValueTask TakeTurnAsync(
List messages,
@@ -42,18 +28,13 @@ protected override async ValueTask TakeTurnAsync(
{
logger.LogDebug("[TravelPlannerExecutor] Starting - parsing user intent");
- await context.AddEventAsync(new ExecutorStatusEvent("Analyzing your request..."));
+ await context.AddEventAsync(new ExecutorStatusEvent("Analyzing your request..."), cancellationToken);
- var runOptions = new ChatClientAgentRunOptions(new ChatOptions
- {
- ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions)
- });
-
- var response = await agent.RunAsync(messages, options: runOptions, cancellationToken: cancellationToken);
+ var response = await agent.RunAsync(messages, cancellationToken: cancellationToken);
logger.LogTrace("[TravelPlannerExecutor] Raw response: {Response}", response.Text);
- var result = JsonSerializer.Deserialize(response.Text, jsonOptions)!;
+ var result = response.Result;
logger.LogDebug("[TravelPlannerExecutor] Completed - extracted: destination={Destination}, days={Days}, language={Language}",
result.DestinationName, result.DayCount, result.Language);
@@ -61,7 +42,7 @@ protected override async ValueTask TakeTurnAsync(
var summary = result.Language != "English"
? $"Planning {result.DayCount}-day trip to {result.DestinationName} in {result.Language}"
: $"Planning {result.DayCount}-day trip to {result.DestinationName}";
- await context.AddEventAsync(new ExecutorStatusEvent(summary));
+ await context.AddEventAsync(new ExecutorStatusEvent(summary), cancellationToken);
await context.SendMessageAsync(result, cancellationToken);
}
diff --git a/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs
index 409968a245d7..ddc9cad04547 100644
--- a/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs
+++ b/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs
@@ -1,40 +1,20 @@
-using System.ComponentModel;
-using System.Text.Json;
-using Maui.Controls.Sample.Models;
-using Maui.Controls.Sample.Services;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
-using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
namespace Maui.Controls.Sample.AI;
///
-/// Agent 2: Researcher - Uses RAG to find candidate destinations, then AI selects the best match.
-/// Uses semantic search (embeddings) to pre-filter destinations, then LLM picks the best one.
+/// Agent 2: Researcher - Uses TextSearchProvider (RAG) to automatically inject matching destinations
+/// into the AI context before each invocation, then the AI selects the best match.
+/// The TextSearchProvider is configured in with BeforeAIInvoke mode, so candidate destinations are
+/// automatically searched and injected.
///
-internal sealed class ResearcherExecutor(AIAgent agent, DataService dataService, JsonSerializerOptions jsonOptions, ILogger logger)
- : Executor("ResearcherExecutor")
+internal sealed partial class ResearcherExecutor(AIAgent agent, ILogger logger)
+ : Executor("ResearcherExecutor")
{
- ///
- /// Maximum number of RAG candidates to return from semantic search.
- ///
- private const int MaxRagCandidates = 5;
-
- public const string Instructions = """
- You are a travel researcher.
- Your job is to select the best matching destination from a list of candidates.
-
- Rules:
- 1. You will be given a list of candidate destinations that semantically match the user's request.
- 2. Select the ONE destination that best matches what the user asked for.
- 3. NEVER make up destinations - only choose from the provided candidates.
- 4. If none of the candidates match well, pick the closest one.
-
- Return the exact name of the best matching destination from the candidates.
- """;
-
- public override async ValueTask HandleAsync(
+ [MessageHandler]
+ private async ValueTask HandleAsync(
TravelPlanResult input,
IWorkflowContext context,
CancellationToken cancellationToken = default)
@@ -42,72 +22,34 @@ public override async ValueTask HandleAsync(
logger.LogDebug("[ResearcherExecutor] Starting - finding best matching destination for '{DestinationName}'", input.DestinationName);
logger.LogTrace("[ResearcherExecutor] Input: {@Input}", input);
- await context.AddEventAsync(new ExecutorStatusEvent("Searching destinations..."));
-
- // Step 1: Use RAG to find semantically similar destinations
- var candidates = await dataService.SearchLandmarksAsync(input.DestinationName, MaxRagCandidates);
-
- logger.LogDebug("[ResearcherExecutor] RAG returned {Count} candidates: {Names}",
- candidates.Count, string.Join(", ", candidates.Select(c => c.Name)));
-
- if (candidates.Count == 0)
- {
- logger.LogDebug("[ResearcherExecutor] No candidates found");
- await context.AddEventAsync(new ExecutorStatusEvent("No matching destinations found"));
- return new ResearchResult(null, input.DayCount, input.Language);
- }
+ await context.AddEventAsync(new ExecutorStatusEvent("Searching destinations..."), cancellationToken);
- // If only one candidate, use it directly without LLM call
- if (candidates.Count == 1)
- {
- var singleMatch = candidates[0];
- logger.LogDebug("[ResearcherExecutor] Single candidate found: {Name}", singleMatch.Name);
- await context.AddEventAsync(new ExecutorStatusEvent($"Found destination: {singleMatch.Name}"));
- return new ResearchResult(singleMatch, input.DayCount, input.Language);
- }
-
- await context.AddEventAsync(new ExecutorStatusEvent($"Evaluating {candidates.Count} candidates..."));
-
- // Step 2: Ask LLM to pick the best match from RAG candidates
- var candidateDescriptions = string.Join("\n", candidates.Select(c =>
- $"- {c.Name}: {c.ShortDescription}"));
-
- var prompt = $"""
- The user wants to visit: "{input.DestinationName}"
-
- Here are the available destinations that might match:
- {candidateDescriptions}
-
- Which destination best matches what the user is looking for?
- """;
+ // TextSearchProvider (configured via CreateAgent) automatically searches
+ // DataService.SearchLandmarksAsync and injects results as context before
+ // the AI call. We just need to ask the AI to pick the best match.
+ var prompt = input.DestinationName;
logger.LogTrace("[ResearcherExecutor] Prompt: {Prompt}", prompt);
- var runOptions = new ChatClientAgentRunOptions(new ChatOptions
- {
- ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions)
- });
-
- var response = await agent.RunAsync(prompt, options: runOptions, cancellationToken: cancellationToken);
+ var response = await agent.RunAsync(prompt, cancellationToken: cancellationToken);
logger.LogTrace("[ResearcherExecutor] Raw response: {Response}", response.Text);
- // Parse the AI's response to get the matched destination name
- var matchResult = JsonSerializer.Deserialize(response.Text, jsonOptions);
- var matchedName = matchResult?.MatchedDestinationName ?? input.DestinationName;
-
- logger.LogDebug("[ResearcherExecutor] AI selected '{MatchedName}' from candidates", matchedName);
+ // Parse the AI's response — both name and description come from RAG context
+ var matchResult = response.Result;
- // Find the landmark from candidates (prefer exact match from candidates)
- var landmark = candidates.FirstOrDefault(l => l.Name.Equals(matchedName, StringComparison.OrdinalIgnoreCase))
- ?? candidates[0]; // Fallback to top RAG result if LLM returned unexpected name
+ logger.LogDebug("[ResearcherExecutor] AI selected '{MatchedName}'", matchResult.MatchedDestinationName);
- var result = new ResearchResult(landmark, input.DayCount, input.Language);
+ var result = new ResearchResult(
+ matchResult.MatchedDestinationName,
+ matchResult.MatchedDestinationDescription,
+ input.DayCount,
+ input.Language);
- logger.LogDebug("[ResearcherExecutor] Completed - selected destination: {Name}", landmark.Name);
+ logger.LogDebug("[ResearcherExecutor] Completed - selected destination: {Name}", matchResult.MatchedDestinationName);
logger.LogTrace("[ResearcherExecutor] Output: {@Result}", result);
- await context.AddEventAsync(new ExecutorStatusEvent($"Found destination: {landmark.Name}"));
+ await context.AddEventAsync(new ExecutorStatusEvent($"Found destination: {matchResult.MatchedDestinationName}"), cancellationToken);
return result;
}
diff --git a/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs
index 52c60c46c301..c70a0e32c0b5 100644
--- a/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs
+++ b/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs
@@ -1,7 +1,4 @@
-using System.ComponentModel;
using System.Text;
-using System.Text.Json;
-using Maui.Controls.Sample.Models;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
@@ -11,71 +8,42 @@ namespace Maui.Controls.Sample.AI;
///
/// Agent 3: Itinerary Planner - Builds the travel itinerary with streaming output.
-/// Tools: findPointsOfInterest(destinationName, category, query)
+/// Tools are used to assist in generating the itinerary.
/// Uses RunStreamingAsync to emit partial JSON as it's generated.
///
-internal sealed class ItineraryPlannerExecutor(AIAgent agent, JsonSerializerOptions jsonOptions, ILogger logger)
- : Executor("ItineraryPlannerExecutor")
+internal sealed partial class ItineraryPlannerExecutor(AIAgent agent, ILogger logger)
+ : Executor("ItineraryPlannerExecutor")
{
- private IWorkflowContext? _context;
-
- public const string Instructions = $"""
- You create detailed travel itineraries.
-
- For each day include these places:
- 1. An activity or attraction
- 2. A hotel recommendation
- 3. A restaurant recommendation
-
- Rules:
- 1. ALWAYS use the `{FindPointsOfInterestToolName}` tool to discover real places near the destination.
- 2. NEVER make up places or use your own knowledge.
- 3. ONLY use places returned by the `{FindPointsOfInterestToolName}` tool.
- 4. PREFER the places returned by the `{FindPointsOfInterestToolName}` tool instead of the destination description.
-
- Give the itinerary a fun, creative title and engaging description.
-
- Include a rationale explaining why you chose these activities for the traveler.
- """;
-
- public const string FindPointsOfInterestToolName = "findPointsOfInterest";
-
- public override async ValueTask HandleAsync(
+ [MessageHandler]
+ private async ValueTask HandleAsync(
ResearchResult input,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
- _context = context;
-
- logger.LogDebug("[ItineraryPlannerExecutor] Starting - building {Days}-day itinerary for '{Landmark}'",
- input.DayCount, input.Landmark?.Name ?? "unknown");
+ logger.LogDebug("[ItineraryPlannerExecutor] Starting - building {Days}-day itinerary for '{Destination}'",
+ input.DayCount, input.DestinationName ?? "unknown");
logger.LogTrace("[ItineraryPlannerExecutor] Input: {@Input}", input);
- await context.AddEventAsync(new ExecutorStatusEvent("Building your itinerary..."));
+ await context.AddEventAsync(new ExecutorStatusEvent("Building your itinerary..."), cancellationToken);
- if (input.Landmark is null)
+ if (input.DestinationName is null)
{
- logger.LogDebug("[ItineraryPlannerExecutor] No landmark found - returning error");
- await context.AddEventAsync(new ExecutorStatusEvent("Error: No destination found"));
- return new ItineraryResult(JsonSerializer.Serialize(new { error = "Landmark not found" }), input.Language);
+ logger.LogDebug("[ItineraryPlannerExecutor] No destination found - returning error");
+ await context.AddEventAsync(new ExecutorStatusEvent("Error: No destination found"), cancellationToken);
+ return new ItineraryResult(System.Text.Json.JsonSerializer.Serialize(new { error = "Destination not found" }), input.Language);
}
var prompt = $"""
- Generate a {input.DayCount}-day itinerary to {input.Landmark.Name}.
- Destination description: {input.Landmark.Description}
+ Generate a {input.DayCount}-day itinerary to {input.DestinationName}.
+ Destination description: {input.DestinationDescription}
""";
logger.LogTrace("[ItineraryPlannerExecutor] Prompt: {Prompt}", prompt);
- var runOptions = new ChatClientAgentRunOptions(new ChatOptions
- {
- Tools = [AIFunctionFactory.Create(FindPointsOfInterestAsync, name: FindPointsOfInterestToolName)],
- ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions)
- });
-
// Use streaming to emit partial JSON as it's generated
+ // Tools and ResponseFormat are configured at agent level in ItineraryWorkflowExtensions
var fullResponse = new StringBuilder();
- await foreach (var update in agent.RunStreamingAsync(prompt, options: runOptions, cancellationToken: cancellationToken))
+ await foreach (var update in agent.RunStreamingAsync(prompt, cancellationToken: cancellationToken))
{
foreach (var content in update.Contents)
{
@@ -92,53 +60,8 @@ Generate a {input.DayCount}-day itinerary to {input.Landmark.Name}.
logger.LogTrace("[ItineraryPlannerExecutor] Raw response: {Response}", responseText);
logger.LogDebug("[ItineraryPlannerExecutor] Completed - itinerary generated, language: {Language}", input.Language);
- await context.AddEventAsync(new ExecutorStatusEvent($"Created {input.DayCount}-day itinerary for {input.Landmark.Name}"));
+ await context.AddEventAsync(new ExecutorStatusEvent($"Created {input.DayCount}-day itinerary for {input.DestinationName}"), cancellationToken);
return new ItineraryResult(responseText, input.Language);
}
-
- [Description("Finds points of interest (hotels, restaurants, activities) near a destination.")]
- private async Task FindPointsOfInterestAsync(
- [Description("The name of the destination to search near.")]
- string destinationName,
- [Description("The category of place to find (Hotel, Restaurant, Cafe, Museum, etc.).")]
- PointOfInterestCategory category,
- [Description("A natural language query to refine the search.")]
- string additionalSearchQuery)
- {
- if (_context is not null)
- {
- await _context.AddEventAsync(new ExecutorStatusEvent($"Finding {category}s near {destinationName}..."));
- }
-
- var suggestions = GetSuggestions(category);
- var result = $"""
- These {category} options are available near {destinationName}:
-
- - {string.Join(Environment.NewLine + "- ", suggestions)}
- """;
-
- logger.LogTrace("[ItineraryPlannerExecutor] findPointsOfInterest tool called - destination={Destination}, category={Category}, query={Query}, result={Result}",
- destinationName, category, additionalSearchQuery ?? "(none)", result);
-
- if (_context is not null)
- {
- await _context.AddEventAsync(new ExecutorStatusEvent($"Found {suggestions.Length} {category} options"));
- }
-
- return result;
- }
-
- private static string[] GetSuggestions(PointOfInterestCategory category) =>
- category switch
- {
- PointOfInterestCategory.Cafe => ["Cafe 1", "Cafe 2", "Cafe 3"],
- PointOfInterestCategory.Campground => ["Campground 1", "Campground 2", "Campground 3"],
- PointOfInterestCategory.Hotel => ["Hotel 1", "Hotel 2", "Hotel 3"],
- PointOfInterestCategory.Marina => ["Marina 1", "Marina 2", "Marina 3"],
- PointOfInterestCategory.Museum => ["Museum 1", "Museum 2", "Museum 3"],
- PointOfInterestCategory.NationalMonument => ["The National Rock 1", "The National Rock 2", "The National Rock 3"],
- PointOfInterestCategory.Restaurant => ["Restaurant 1", "Restaurant 2", "Restaurant 3"],
- _ => []
- };
}
diff --git a/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs
index 4e582c7b8a28..122494ece73e 100644
--- a/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs
+++ b/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs
@@ -1,6 +1,4 @@
using System.Text;
-using System.Text.Json;
-using Maui.Controls.Sample.Models;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
@@ -10,22 +8,14 @@ namespace Maui.Controls.Sample.AI;
///
/// Agent 4: Translator - Translates the itinerary to target language (conditional) with streaming.
-/// No tools - just translation. Uses RunStreamingAsync to emit partial translated JSON.
+/// No tools - just translation.
+/// Uses RunStreamingAsync to emit partial translated JSON.
///
-internal sealed class TranslatorExecutor(AIAgent agent, JsonSerializerOptions jsonOptions, ILogger logger)
- : Executor("TranslatorExecutor")
+internal sealed partial class TranslatorExecutor(AIAgent agent, ILogger logger)
+ : Executor("TranslatorExecutor")
{
- public const string Instructions = """
- You are a professional translator.
- Translate the provided JSON content to the target language.
-
- Rules:
- 1. ALWAYS preserve the JSON format exactly.
- 2. ONLY translate the text values within the JSON.
- 3. NEVER add explanations or commentary.
- """;
-
- public override async ValueTask HandleAsync(
+ [MessageHandler]
+ private async ValueTask HandleAsync(
ItineraryResult input,
IWorkflowContext context,
CancellationToken cancellationToken = default)
@@ -33,12 +23,7 @@ public override async ValueTask HandleAsync(
logger.LogDebug("[TranslatorExecutor] Starting - translating to '{Language}'", input.TargetLanguage);
logger.LogTrace("[TranslatorExecutor] Input JSON: {Json}", input.ItineraryJson);
- await context.AddEventAsync(new ExecutorStatusEvent($"Translating to {input.TargetLanguage}..."));
-
- var runOptions = new ChatClientAgentRunOptions(new ChatOptions
- {
- ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions)
- });
+ await context.AddEventAsync(new ExecutorStatusEvent($"Translating to {input.TargetLanguage}..."), cancellationToken);
var prompt = $"""
Translate to {input.TargetLanguage}:
@@ -49,8 +34,9 @@ public override async ValueTask HandleAsync(
logger.LogTrace("[TranslatorExecutor] Prompt: {Prompt}", prompt);
// Use streaming to emit partial JSON as it's generated
+ // ResponseFormat is set at agent creation time in ItineraryWorkflowExtensions
var fullResponse = new StringBuilder();
- await foreach (var update in agent.RunStreamingAsync(prompt, options: runOptions, cancellationToken: cancellationToken))
+ await foreach (var update in agent.RunStreamingAsync(prompt, cancellationToken: cancellationToken))
{
foreach (var content in update.Contents)
{
@@ -67,7 +53,7 @@ public override async ValueTask HandleAsync(
logger.LogTrace("[TranslatorExecutor] Raw response: {Response}", responseText);
logger.LogDebug("[TranslatorExecutor] Completed - translation to '{Language}' finished", input.TargetLanguage);
- await context.AddEventAsync(new ExecutorStatusEvent($"Translated to {input.TargetLanguage}"));
+ await context.AddEventAsync(new ExecutorStatusEvent($"Translated to {input.TargetLanguage}"), cancellationToken);
return new ItineraryResult(responseText, input.TargetLanguage);
}
diff --git a/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs
index 8b046e0f2a56..85610945ad9e 100644
--- a/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs
+++ b/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs
@@ -7,10 +7,11 @@ namespace Maui.Controls.Sample.AI;
/// Final executor that marks the workflow as complete.
/// The itinerary JSON has already been streamed by ItineraryPlannerExecutor or TranslatorExecutor.
///