From 8787cf1d8c6a0aeb6c78f56f8ba64a510b7d100a Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 17:36:52 -0400 Subject: [PATCH 01/24] feat: Add reflection invoke for DevFlow Actions and arbitrary methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-tier invoke system for AI agents to rapidly set up app state: Tier 1 — DevFlow Actions: Methods annotated with [DevFlowAction] are discoverable via maui_list_actions. Rich parameter metadata via [Description] attributes, just like MCP tools. Validated at compile time by a new Roslyn analyzer (MAUI_DFA001-004). Tier 2 — Open reflection invoke: Call any public static method or DI-resolved service method by type+method name. Enables AI to call helpers it discovers from source code, even without [DevFlowAction]. Changes across all DevFlow layers: - Agent.Core: 5 HTTP endpoints, assembly scanning, type resolution, parameter conversion (primitives, enums, arrays/lists, nullable), async method unwrapping (Task/Task), batch integration - Driver: 5 AgentClient methods + InvokeResult DTO + source-gen registration - CLI/MCP: 4 tools (maui_list_actions, maui_invoke_action, maui_invoke, maui_list_methods) with rich [Description] text - Analyzer: New Microsoft.Maui.DevFlow.Analyzers project (netstandard2.0) with 4 diagnostics for compile-time validation of [DevFlowAction] methods - Skill: New devflow-automation skill teaching AI agents when and how to use invoke as a first-class automation technique - Plugin version bumped 0.1.0 → 0.2.0 Tests: 11 new tests covering action discovery, invoke by name, invoke by type+method, async methods, void methods, bool params, error cases. All 158 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 + MauiLabs.slnx | 1 + eng/Versions.props | 1 + plugins/dotnet-maui/plugin.json | 2 +- .../skills/devflow-automation/SKILL.md | 218 ++++++ .../DevFlow/Mcp/McpServerHost.cs | 3 +- .../DevFlow/Mcp/Tools/InvokeTools.cs | 159 +++++ src/DevFlow/DevFlow.slnf | 1 + .../DevFlowActionAttribute.cs | 77 +++ .../DevFlowAgentService.Invoke.cs | 619 ++++++++++++++++++ .../DevFlowAgentService.cs | 45 +- .../Microsoft.Maui.DevFlow.Agent.Core.csproj | 3 + .../AnalyzerReleases.Shipped.md | 10 + .../AnalyzerReleases.Unshipped.md | 1 + .../DevFlowActionAnalyzer.cs | 236 +++++++ .../Microsoft.Maui.DevFlow.Analyzers.csproj | 20 + .../AgentClient.cs | 81 +++ .../DevFlowDriverJson.cs | 1 + .../InvokeTests.cs | 318 +++++++++ 19 files changed, 1794 insertions(+), 3 deletions(-) create mode 100644 plugins/dotnet-maui/skills/devflow-automation/SKILL.md create mode 100644 src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs create mode 100644 src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowActionAttribute.cs create mode 100644 src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs create mode 100644 src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Shipped.md create mode 100644 src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md create mode 100644 src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/DevFlowActionAnalyzer.cs create mode 100644 src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/Microsoft.Maui.DevFlow.Analyzers.csproj create mode 100644 src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a25eefb89..48ce80f07 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,6 +24,7 @@ + diff --git a/MauiLabs.slnx b/MauiLabs.slnx index 720014960..d5bee3e97 100644 --- a/MauiLabs.slnx +++ b/MauiLabs.slnx @@ -20,6 +20,7 @@ + diff --git a/eng/Versions.props b/eng/Versions.props index 7c2d91966..2e9e3309f 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -32,6 +32,7 @@ 1.3.1 + 4.12.0 3.119.2 0.54.0 2.0.5 diff --git a/plugins/dotnet-maui/plugin.json b/plugins/dotnet-maui/plugin.json index 86e0d2ee2..ab7ea8b44 100644 --- a/plugins/dotnet-maui/plugin.json +++ b/plugins/dotnet-maui/plugin.json @@ -1,6 +1,6 @@ { "name": "dotnet-maui", - "version": "0.1.0", + "version": "0.2.0", "description": "Skills for .NET MAUI development: DevFlow automation, profiling, accessibility, platform bindings, and diagnostics. Some skills require the maui CLI tool.", "skills": ["./skills/"] } diff --git a/plugins/dotnet-maui/skills/devflow-automation/SKILL.md b/plugins/dotnet-maui/skills/devflow-automation/SKILL.md new file mode 100644 index 000000000..63e7c3ba4 --- /dev/null +++ b/plugins/dotnet-maui/skills/devflow-automation/SKILL.md @@ -0,0 +1,218 @@ +--- +name: devflow-automation +description: >- + Automate .NET MAUI app state via DevFlow reflection invoke and registered + actions. USE FOR: calling app methods via reflection, discovering and invoking + [DevFlowAction] shortcuts, logging in test users, seeding data, navigating to + deep screens, bypassing UI flows to reach target state quickly, calling DI + service methods. DO NOT USE FOR: basic UI interaction (tap/fill/scroll — use + DevFlow MCP tools directly), visual tree inspection, screenshot capture, + connectivity issues (use devflow-connect), or build/deployment problems. +--- + +# DevFlow Automation — Reflection Invoke + +Invoke methods in a running .NET MAUI app via reflection to rapidly set up app state. This is your most powerful tool for reducing round-trip steps when debugging or testing. + +## Why This Matters + +Traditional DevFlow interaction (navigate → fill → tap → screenshot → repeat) works but is slow for multi-step flows like authentication, data setup, or deep navigation. If the app has helper methods — especially in debug builds — you can call them directly and skip the UI entirely. + +**Always check for available actions first.** A single `maui_list_actions` call can reveal shortcuts that save dozens of UI interaction steps. + +## Two-Tier System + +### Tier 1: Registered DevFlow Actions (Preferred) + +App developers annotate methods with `[DevFlowAction]` to expose named, documented shortcuts: + +```csharp +[DevFlowAction("login-test-user", Description = "Log in as the standard test account")] +public static async Task LoginTestUser( + [Description("Email address")] string email = "test@example.com", + [Description("Password")] string password = "password123") +{ + await AuthService.LoginAsync(email, password); +} +``` + +**Discover actions:** +``` +maui_list_actions +``` + +**Invoke an action:** +``` +maui_invoke_action actionName="login-test-user" +maui_invoke_action actionName="login-test-user" argsJson='["alice@test.com", "secret"]' +maui_invoke_action actionName="seed-catalog" argsJson='[100]' +``` + +### Tier 2: Open Reflection Invoke (Flexible) + +When no registered action exists, call any public method by type and method name: + +**Static methods:** +``` +maui_invoke typeName="MyApp.DebugHelpers" methodName="ResetDatabase" +maui_invoke typeName="MyApp.DebugHelpers" methodName="LoginTestUser" argsJson='["user@test.com", "pass"]' +``` + +**DI service methods:** +``` +maui_invoke typeName="MyApp.Services.IAuthService" methodName="LoginAsync" argsJson='["user@test.com", "pass"]' resolve="service" +``` + +**Discover methods on a type:** +``` +maui_list_methods typeName="MyApp.DebugHelpers" +``` + +## When to Use Each Approach + +| Scenario | Approach | Why | +|----------|----------|-----| +| Starting a session — check what's available | `maui_list_actions` | Discover shortcuts before doing anything manual | +| App has a known debug helper | `maui_invoke_action` | Named, documented, safe to call | +| You know the source code has a useful method | `maui_invoke` with type+method | Direct reflection, no registration needed | +| You need to call a registered DI service | `maui_invoke` with `resolve="service"` | Resolves from the app's DI container | +| Need to explore what's callable | `maui_list_methods` | See all public methods on a type | +| Simple UI interaction (tap, type, scroll) | Use `maui_tap`, `maui_fill`, etc. | Standard DevFlow tools, no reflection needed | + +## Workflow: Efficient App State Setup + +### Step 1: Check for Registered Actions + +``` +maui_list_actions +``` + +Look for actions that match your goal. Common patterns: +- `login-*` — authentication shortcuts +- `seed-*` — data population +- `navigate-*` — deep navigation +- `set-*` — feature flags, configuration +- `reset-*` — state cleanup + +### Step 2: Use Actions or Fall Back to Invoke + +If an action exists, invoke it. If not, check the app source for helper methods and use `maui_invoke`. + +### Step 3: Verify with Screenshot + +After invoking, take a screenshot to confirm the app reached the expected state: + +``` +maui_screenshot +``` + +### Step 4: Continue with Standard Tools + +Once the app is in the right state, use standard DevFlow tools (tree, tap, fill, etc.) for fine-grained interaction. + +## Supported Parameter Types + +Arguments are passed as a JSON array. These types are auto-converted: + +| Type | JSON Example | +|------|-------------| +| `string` | `"hello"` | +| `bool` | `true` or `false` | +| `int`, `long`, `short`, `byte` | `42` | +| `float`, `double`, `decimal` | `3.14` | +| `enum` | `"MemberName"` (case-insensitive) | +| `string[]`, `int[]`, etc. | `["a", "b", "c"]` or `[1, 2, 3]` | +| `List` | Same as arrays | +| Nullable types | `null` or the value | + +## Batch Support + +Invoke actions as part of a batch for complex setup sequences: + +```json +{ + "actions": [ + {"action": "invoke-action", "name": "login-test-user"}, + {"action": "invoke", "typeName": "MyApp.Debug", "methodName": "NavigateTo", "args": ["settings"]}, + {"action": "tap", "elementId": "btn-advanced"} + ] +} +``` + +## For App Developers: Adding DevFlow Actions + +### 1. Add the Attribute + +```csharp +using System.ComponentModel; +using Microsoft.Maui.DevFlow.Agent.Core; + +public static class DebugHelpers +{ + [DevFlowAction("login-test-user", Description = "Log in as the standard test account")] + public static async Task LoginTestUser( + [Description("Email address for the test account")] string email = "test@example.com", + [Description("Password for the test account")] string password = "password123") + { + await AuthService.LoginAsync(email, password); + } +} +``` + +### 2. Rules + +- Methods **must be `public static`** (enforced by analyzer: MAUI_DFA002) +- Parameter types must be supported primitives, enums, or arrays/lists of these (MAUI_DFA001) +- Add `[Description]` to parameters so AI agents know what to pass (MAUI_DFA004) +- Return `void`, `Task`, or `Task` with a simple type (MAUI_DFA003 warns on complex returns) + +### 3. Roslyn Analyzer + +The `Microsoft.Maui.DevFlow.Agent.Core` NuGet package includes a Roslyn analyzer that validates `[DevFlowAction]` methods at compile time: + +| Diagnostic | Severity | Description | +|-----------|----------|-------------| +| MAUI_DFA001 | Error | Unsupported parameter type | +| MAUI_DFA002 | Error | Method must be public static | +| MAUI_DFA003 | Warning | Return type may not serialize cleanly | +| MAUI_DFA004 | Info | Missing `[Description]` on parameter | + +## Capabilities Detection + +Check if the connected agent supports invoke: + +``` +maui_status +``` + +The capabilities response includes an `invoke` section when supported. This handles version mismatches gracefully — if the app uses an older DevFlow agent without invoke support, the tools will report this clearly. + +## Common Patterns + +### Authentication Bypass + +``` +maui_list_actions # Check for login actions +maui_invoke_action actionName="login-test-user" # Use the shortcut +maui_screenshot # Verify logged-in state +``` + +### Data Seeding + +``` +maui_invoke_action actionName="seed-catalog" argsJson='[200]' +maui_invoke_action actionName="seed-orders" argsJson='[50, true]' +``` + +### Feature Flag Override + +``` +maui_invoke typeName="MyApp.FeatureFlags" methodName="Enable" argsJson='["dark-mode"]' +maui_invoke typeName="MyApp.FeatureFlags" methodName="Enable" argsJson='["experimental-ui"]' +``` + +### Navigate to Deep Screen + +``` +maui_invoke_action actionName="navigate-to" argsJson='["//settings/advanced/network"]' +``` diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/McpServerHost.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/McpServerHost.cs index 43fa6a386..fad375b5f 100644 --- a/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/McpServerHost.cs +++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/McpServerHost.cs @@ -47,7 +47,8 @@ public static async Task RunAsync() .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); await builder.Build().RunAsync(); } diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs new file mode 100644 index 000000000..3b5c43c91 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs @@ -0,0 +1,159 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using ModelContextProtocol.Server; +using Microsoft.Maui.Cli.DevFlow.Mcp; + +namespace Microsoft.Maui.Cli.DevFlow.Mcp.Tools; + +[McpServerToolType] +public sealed class InvokeTools +{ + [McpServerTool(Name = "maui_list_actions"), Description(""" + List all registered DevFlow Actions — named shortcuts the app developer has exposed + for automation. Each action has a name, description, and typed parameters. + + Actions are methods annotated with [DevFlowAction] in the app's code. They're designed + to be called by AI agents to quickly set up app state (e.g., login, seed data, + navigate to a specific screen) without stepping through the UI manually. + + Call this tool early when starting a DevFlow session — available actions can + dramatically reduce the number of steps needed to reach a desired app state. + """)] + public static async Task ListActions( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.ListActionsAsync(); + return CliJson.SerializeUntyped(result, indented: false); + } + + [McpServerTool(Name = "maui_invoke_action"), Description(""" + Invoke a registered DevFlow Action by name. Actions are named shortcuts the app + developer has exposed — use maui_list_actions first to discover what's available. + + Arguments are passed as a JSON array matching the action's parameter order. + Parameters with default values can be omitted. Supported types: string, bool, + int, long, float, double, decimal, enum values (by name), and arrays of these. + + Example: To invoke "login-test-user" with email and password: + actionName: "login-test-user" + argsJson: '["alice@example.com", "secret123"]' + + Example: To invoke "seed-catalog" with just a count (using default for other params): + actionName: "seed-catalog" + argsJson: '[100]' + """)] + public static async Task InvokeAction( + McpAgentSession session, + [Description("Name of the DevFlow Action to invoke (from maui_list_actions)")] string actionName, + [Description("JSON array of arguments matching the action's parameter order. Omit trailing optional params. Example: '[\"hello\", 42, true]'")] string? argsJson = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + JsonArray? args = null; + if (!string.IsNullOrWhiteSpace(argsJson)) + { + try + { + var node = JsonNode.Parse(argsJson); + if (node is not JsonArray array) + return $"Invalid argsJson: expected a JSON array, got {node?.GetValueKind().ToString() ?? "null"}."; + args = array; + } + catch (JsonException ex) + { + return $"Invalid JSON in argsJson: {ex.Message}"; + } + } + + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.InvokeActionAsync(actionName, args); + + if (result == null) + return $"Failed to invoke action '{actionName}'. Verify the app is running and the agent supports invoke."; + + return result.Success + ? $"Action '{actionName}' completed.{(result.ReturnValue != null ? $" Result: {result.ReturnValue}" : "")}" + : $"Action '{actionName}' failed: {result.Error}"; + } + + [McpServerTool(Name = "maui_invoke"), Description(""" + Invoke any public method on a type in the running app via reflection. + Use this when you need to call a method that isn't registered as a DevFlow Action, + such as a static helper method you've identified in the app's source code. + + Supports two resolution modes: + - "static" (default): Calls a static method on the specified type. + - "service": Resolves the type from the app's DI container, then calls an instance method. + + Arguments are a JSON array of values matching parameter order. Types are auto-converted. + + Example (static): Invoke MyApp.DebugHelpers.ResetDatabase() + typeName: "MyApp.DebugHelpers" + methodName: "ResetDatabase" + + Example (DI service): Call LoginAsync on IAuthService + typeName: "MyApp.Services.IAuthService" + methodName: "LoginAsync" + argsJson: '["user@test.com", "pass123"]' + resolve: "service" + + For methods on UI elements, use maui_invoke with an element reference, + or call maui_invoke_action for registered shortcuts. + """)] + public static async Task Invoke( + McpAgentSession session, + [Description("Fully-qualified or simple type name (e.g., 'MyApp.DebugHelpers' or 'DebugHelpers')")] string typeName, + [Description("Method name to invoke (case-insensitive)")] string methodName, + [Description("JSON array of arguments. Example: '[\"hello\", 42, true]'")] string? argsJson = null, + [Description("Resolution mode: 'static' (default) for static methods, 'service' to resolve from DI container")] string? resolve = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + JsonArray? args = null; + if (!string.IsNullOrWhiteSpace(argsJson)) + { + try + { + var node = JsonNode.Parse(argsJson); + if (node is not JsonArray array) + return $"Invalid argsJson: expected a JSON array, got {node?.GetValueKind().ToString() ?? "null"}."; + args = array; + } + catch (JsonException ex) + { + return $"Invalid JSON in argsJson: {ex.Message}"; + } + } + + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.InvokeAsync(typeName, methodName, args, resolve); + + if (result == null) + return $"Failed to invoke {typeName}.{methodName}. Verify the app is running and the agent supports invoke."; + + return result.Success + ? $"Invoked {typeName}.{methodName}().{(result.ReturnValue != null ? $" Result: {result.ReturnValue}" : "")}" + : $"Invoke failed: {result.Error}"; + } + + [McpServerTool(Name = "maui_list_methods"), Description(""" + Discover public methods on a type in the running app. Returns method signatures with + parameter names, types, descriptions, and default values. + + Use this to explore what methods are available on a type before calling maui_invoke. + Methods annotated with [DevFlowAction] are flagged in the results. + + Example: List methods on a debug helper class + typeName: "MyApp.DebugHelpers" + """)] + public static async Task ListMethods( + McpAgentSession session, + [Description("Fully-qualified or simple type name to discover methods on")] string typeName, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.ListMethodsAsync(typeName); + return CliJson.SerializeUntyped(result, indented: false); + } +} diff --git a/src/DevFlow/DevFlow.slnf b/src/DevFlow/DevFlow.slnf index a795b4a7f..c3f4ddb9f 100644 --- a/src/DevFlow/DevFlow.slnf +++ b/src/DevFlow/DevFlow.slnf @@ -3,6 +3,7 @@ "path": "../../MauiLabs.slnx", "projects": [ "src\\DevFlow\\Microsoft.Maui.DevFlow.Agent.Core\\Microsoft.Maui.DevFlow.Agent.Core.csproj", + "src\\DevFlow\\Microsoft.Maui.DevFlow.Analyzers\\Microsoft.Maui.DevFlow.Analyzers.csproj", "src\\DevFlow\\Microsoft.Maui.DevFlow.Agent\\Microsoft.Maui.DevFlow.Agent.csproj", "src\\DevFlow\\Microsoft.Maui.DevFlow.Agent.Gtk\\Microsoft.Maui.DevFlow.Agent.Gtk.csproj", "src\\DevFlow\\Microsoft.Maui.DevFlow.Blazor\\Microsoft.Maui.DevFlow.Blazor.csproj", diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowActionAttribute.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowActionAttribute.cs new file mode 100644 index 000000000..21f1d86db --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowActionAttribute.cs @@ -0,0 +1,77 @@ +namespace Microsoft.Maui.DevFlow.Agent.Core; + +/// +/// Marks a method as a DevFlow Action — a named, discoverable shortcut that AI agents +/// and the maui devflow invoke CLI can call at runtime. +/// +/// +/// Use this to expose debug/test helpers (e.g., auto-login, seed data, navigate to a +/// deep screen) so that AI agents can discover and invoke them instead of manually +/// stepping through the UI. +/// +/// +/// +/// Annotate parameters with +/// to provide AI-visible documentation for each parameter. +/// +/// +/// +/// +/// [DevFlowAction("login-test-user", Description = "Log in as the standard test account")] +/// public static async Task LoginTestUser( +/// [Description("Email address for the test account")] string email = "test@example.com", +/// [Description("Password for the test account")] string password = "password123") +/// { +/// await AuthService.LoginAsync(email, password); +/// } +/// +/// +/// +/// +/// Supported parameter types: +/// +/// string, bool +/// int, long, short, byte +/// float, double, decimal +/// Any enum type +/// Arrays or lists of the above: string[], List<int>, etc. +/// Nullable<T> of any supported value type +/// +/// +/// +/// Methods must be public static. Return type should be void, +/// Task, or Task<T> where T is a supported type or any +/// type whose ToString() produces a meaningful result. +/// +/// +/// +/// The Roslyn analyzer Microsoft.Maui.DevFlow.Analyzers validates these +/// constraints at compile time when the analyzer is present. +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class DevFlowActionAttribute : Attribute +{ + /// + /// The unique action name used to invoke this method via DevFlow tooling. + /// Use kebab-case (e.g., "login-test-user", "seed-catalog"). + /// + public string Name { get; } + + /// + /// A human-readable description of what this action does. + /// AI agents see this when discovering available actions. + /// + public string? Description { get; set; } + + /// + /// Creates a new DevFlow Action attribute. + /// + /// + /// Unique action name in kebab-case (e.g., "login-test-user"). + /// + public DevFlowActionAttribute(string name) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + } +} diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs new file mode 100644 index 000000000..a777a4807 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -0,0 +1,619 @@ +using System.ComponentModel; +using System.Reflection; +using System.Text.Json; + +namespace Microsoft.Maui.DevFlow.Agent.Core; + +// Invoke / reflection endpoints +public partial class DevFlowAgentService +{ + private InvokeActionEntry[]? _cachedActions; + private readonly Dictionary _typeResolutionCache = new(StringComparer.OrdinalIgnoreCase); + + #region Action Discovery + + private InvokeActionEntry[] DiscoverActions() + { + if (_cachedActions != null) + return _cachedActions; + + var actions = new List(); + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (asm.IsDynamic || IsFrameworkAssembly(asm)) + continue; + + Type[] types; + try { types = asm.GetTypes(); } + catch { continue; } + + foreach (var type in types) + { + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + var attr = method.GetCustomAttribute(); + if (attr == null) continue; + + actions.Add(new InvokeActionEntry + { + Name = attr.Name, + Description = attr.Description, + DeclaringType = type.FullName ?? type.Name, + Method = method, + Parameters = BuildParameterInfoList(method) + }); + } + } + } + + _cachedActions = actions.ToArray(); + return _cachedActions; + } + + private static bool IsFrameworkAssembly(Assembly asm) + { + var name = asm.GetName().Name; + if (name == null) return true; + return name.StartsWith("System", StringComparison.Ordinal) + || name.StartsWith("Microsoft.Extensions", StringComparison.Ordinal) + || name.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal) + || name.StartsWith("netstandard", StringComparison.Ordinal) + || name.StartsWith("mscorlib", StringComparison.Ordinal) + || name.StartsWith("Fizzler", StringComparison.Ordinal) + || name.StartsWith("SkiaSharp", StringComparison.Ordinal); + } + + private static InvokeParameterInfo[] BuildParameterInfoList(MethodInfo method) + { + return method.GetParameters().Select(p => new InvokeParameterInfo + { + Name = p.Name ?? "arg", + Type = FormatParameterTypeName(p.ParameterType), + Description = p.GetCustomAttribute()?.Description, + DefaultValue = p.HasDefaultValue ? FormatDefaultValue(p.DefaultValue) : null, + IsRequired = !p.HasDefaultValue && !p.ParameterType.IsAssignableTo(typeof(Nullable<>)) + }).ToArray(); + } + + private static string FormatParameterTypeName(Type type) + { + var underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) + return FormatParameterTypeName(underlying) + "?"; + + if (type.IsArray) + return FormatParameterTypeName(type.GetElementType()!) + "[]"; + + if (type.IsGenericType) + { + var def = type.GetGenericTypeDefinition(); + if (def == typeof(List<>) || def == typeof(IList<>) || def == typeof(IEnumerable<>) || def == typeof(IReadOnlyList<>)) + return FormatParameterTypeName(type.GetGenericArguments()[0]) + "[]"; + } + + return Type.GetTypeCode(type) switch + { + TypeCode.String => "string", + TypeCode.Boolean => "bool", + TypeCode.Int32 => "int", + TypeCode.Int64 => "long", + TypeCode.Int16 => "short", + TypeCode.Byte => "byte", + TypeCode.Single => "float", + TypeCode.Double => "double", + TypeCode.Decimal => "decimal", + _ => type.IsEnum ? $"enum({type.Name})" : type.Name + }; + } + + private static string? FormatDefaultValue(object? value) + { + if (value == null) return "null"; + if (value is string s) return s; + if (value is bool b) return b ? "true" : "false"; + return value.ToString(); + } + + #endregion + + #region Type Resolution + + private Type? ResolveType(string typeName) + { + if (_typeResolutionCache.TryGetValue(typeName, out var cached)) + return cached; + + // Try fully-qualified name first + var type = Type.GetType(typeName); + if (type != null) + { + _typeResolutionCache[typeName] = type; + return type; + } + + // Scan loaded assemblies + Type? bestMatch = null; + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (asm.IsDynamic) continue; + + // Full name match (preferred) + type = asm.GetType(typeName, throwOnError: false, ignoreCase: true); + if (type != null) + { + _typeResolutionCache[typeName] = type; + return type; + } + + // Simple name match (fallback for unqualified names) + if (!typeName.Contains('.')) + { + try + { + foreach (var t in asm.GetTypes()) + { + if (string.Equals(t.Name, typeName, StringComparison.OrdinalIgnoreCase)) + { + if (IsFrameworkAssembly(asm)) + continue; // prefer app types over framework types + bestMatch = t; + } + } + } + catch { } + } + } + + if (bestMatch != null) + _typeResolutionCache[typeName] = bestMatch; + + return bestMatch; + } + + #endregion + + #region Parameter Conversion + + private static object? ConvertInvokeArg(Type targetType, JsonElement argElement) + { + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + + // Null handling + if (argElement.ValueKind == JsonValueKind.Null) + { + if (Nullable.GetUnderlyingType(targetType) != null || !targetType.IsValueType) + return null; + throw new ArgumentException($"Cannot pass null for non-nullable type {targetType.Name}"); + } + + // String + if (underlying == typeof(string)) + return argElement.GetString(); + + // Boolean + if (underlying == typeof(bool)) + { + if (argElement.ValueKind == JsonValueKind.True || argElement.ValueKind == JsonValueKind.False) + return argElement.GetBoolean(); + return bool.Parse(argElement.GetString()!); + } + + // Integer types + if (underlying == typeof(int)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt32() : int.Parse(argElement.GetString()!); + if (underlying == typeof(long)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt64() : long.Parse(argElement.GetString()!); + if (underlying == typeof(short)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt16() : short.Parse(argElement.GetString()!); + if (underlying == typeof(byte)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetByte() : byte.Parse(argElement.GetString()!); + + // Floating point + if (underlying == typeof(float)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetSingle() : float.Parse(argElement.GetString()!); + if (underlying == typeof(double)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetDouble() : double.Parse(argElement.GetString()!); + if (underlying == typeof(decimal)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetDecimal() : decimal.Parse(argElement.GetString()!); + + // Enums + if (underlying.IsEnum) + { + var s = argElement.GetString() ?? argElement.GetRawText(); + return Enum.Parse(underlying, s, ignoreCase: true); + } + + // Arrays and lists + if (argElement.ValueKind == JsonValueKind.Array) + { + Type? elementType = null; + + if (underlying.IsArray) + elementType = underlying.GetElementType()!; + else if (underlying.IsGenericType) + { + var def = underlying.GetGenericTypeDefinition(); + if (def == typeof(List<>) || def == typeof(IList<>) || def == typeof(IEnumerable<>) || def == typeof(IReadOnlyList<>)) + elementType = underlying.GetGenericArguments()[0]; + } + + if (elementType != null) + { + var items = new List(); + foreach (var item in argElement.EnumerateArray()) + items.Add(ConvertInvokeArg(elementType, item)); + + if (underlying.IsArray) + { + var arr = Array.CreateInstance(elementType, items.Count); + for (int i = 0; i < items.Count; i++) + arr.SetValue(items[i], i); + return arr; + } + + var listType = typeof(List<>).MakeGenericType(elementType); + var list = (System.Collections.IList)Activator.CreateInstance(listType)!; + foreach (var item in items) + list.Add(item); + return list; + } + } + + // Fallback: treat as string + if (argElement.ValueKind == JsonValueKind.String) + return argElement.GetString(); + + throw new ArgumentException($"Cannot convert JSON {argElement.ValueKind} to {targetType.Name}"); + } + + private static object?[] ConvertInvokeArgs(ParameterInfo[] parameters, JsonElement[]? args) + { + var result = new object?[parameters.Length]; + for (int i = 0; i < parameters.Length; i++) + { + if (args != null && i < args.Length) + { + result[i] = ConvertInvokeArg(parameters[i].ParameterType, args[i]); + } + else if (parameters[i].HasDefaultValue) + { + result[i] = parameters[i].DefaultValue; + } + else + { + throw new ArgumentException($"Missing required argument '{parameters[i].Name}' (parameter {i})"); + } + } + return result; + } + + #endregion + + #region Invoke Execution + + private static async Task<(bool success, string? returnValue, string? returnType, string? error)> InvokeMethodAsync( + MethodInfo method, object? target, object?[] args) + { + try + { + var result = method.Invoke(target, args); + + // Handle async methods + if (result is Task task) + { + await task; + + var taskType = task.GetType(); + if (taskType.IsGenericType) + { + // Task — unwrap the result + var resultProp = taskType.GetProperty("Result"); + var taskResult = resultProp?.GetValue(task); + return (true, FormatPropertyValue(taskResult), FormatParameterTypeName(taskType.GetGenericArguments()[0]), null); + } + + return (true, null, "void", null); + } + + // Void method + if (method.ReturnType == typeof(void)) + return (true, null, "void", null); + + // Synchronous return value + return (true, FormatPropertyValue(result), FormatParameterTypeName(method.ReturnType), null); + } + catch (TargetInvocationException tie) + { + var inner = tie.InnerException ?? tie; + return (false, null, null, $"{inner.GetType().Name}: {inner.Message}"); + } + catch (Exception ex) + { + return (false, null, null, $"{ex.GetType().Name}: {ex.Message}"); + } + } + + #endregion + + #region HTTP Handlers + + private static HttpResponse InvokeError(string error) => + HttpResponse.Json(new { success = false, error }); + + private Task HandleListActions(HttpRequest request) + { + var actions = DiscoverActions(); + var result = actions.Select(a => new + { + name = a.Name, + description = a.Description, + declaringType = a.DeclaringType, + parameters = a.Parameters.Select(p => new + { + name = p.Name, + type = p.Type, + description = p.Description, + defaultValue = p.DefaultValue, + isRequired = p.IsRequired + }) + }); + return Task.FromResult(HttpResponse.Json(new { actions = result })); + } + + private async Task HandleInvokeAction(HttpRequest request) + { + if (!request.RouteParams.TryGetValue("name", out var actionName)) + return InvokeError("Action name required"); + + var actions = DiscoverActions(); + var action = Array.Find(actions, a => string.Equals(a.Name, actionName, StringComparison.OrdinalIgnoreCase)); + if (action == null) + return InvokeError($"Action '{actionName}' not found. Use GET /api/v1/invoke/actions to list available actions."); + + JsonElement[]? args = null; + if (request.Body != null) + { + var body = request.BodyAs(); + args = body?.Args; + } + + try + { + var convertedArgs = ConvertInvokeArgs(action.Method.GetParameters(), args); + var (success, returnValue, returnType, error) = await InvokeMethodAsync(action.Method, null, convertedArgs); + + return success + ? HttpResponse.Json(new { success = true, action = action.Name, returnValue, returnType }) + : InvokeError($"Action '{actionName}' failed: {error}"); + } + catch (ArgumentException ex) + { + return InvokeError($"Argument error: {ex.Message}"); + } + } + + private async Task HandleInvoke(HttpRequest request) + { + var body = request.BodyAs(); + if (body?.TypeName == null) + return InvokeError("typeName is required"); + if (body.MethodName == null) + return InvokeError("methodName is required"); + + var type = ResolveType(body.TypeName); + if (type == null) + return InvokeError($"Type '{body.TypeName}' not found in loaded assemblies."); + + var resolve = body.Resolve ?? "static"; + var isService = string.Equals(resolve, "service", StringComparison.OrdinalIgnoreCase); + + var bindingFlags = BindingFlags.Public | BindingFlags.IgnoreCase + | (isService ? BindingFlags.Instance : BindingFlags.Static); + + var method = type.GetMethod(body.MethodName, bindingFlags); + if (method == null) + { + // Try finding by parameter count for overload resolution + var candidates = type.GetMethods(bindingFlags) + .Where(m => string.Equals(m.Name, body.MethodName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (candidates.Length == 0) + return InvokeError($"Method '{body.MethodName}' not found on type '{type.FullName}'."); + + var argCount = body.Args?.Length ?? 0; + method = candidates.FirstOrDefault(m => + { + var ps = m.GetParameters(); + var required = ps.Count(p => !p.HasDefaultValue); + return argCount >= required && argCount <= ps.Length; + }) ?? candidates[0]; + } + + object? target = null; + if (isService) + { + target = await DispatchAsync(() => + { + var sp = _app?.Handler?.MauiContext?.Services; + return sp?.GetService(type); + }); + + if (target == null) + return InvokeError($"Could not resolve type '{type.FullName}' from DI container. Ensure it is registered in the app's service collection."); + } + + try + { + var convertedArgs = ConvertInvokeArgs(method.GetParameters(), body.Args); + + bool success; string? returnValue; string? returnType; string? error; + if (isService) + { + // Service invoke must run on UI thread since the service may access UI state + var invokeTask = await DispatchAsync(() => InvokeMethodAsync(method, target, convertedArgs)); + (success, returnValue, returnType, error) = await invokeTask; + } + else + { + (success, returnValue, returnType, error) = await InvokeMethodAsync(method, target, convertedArgs); + } + + return success + ? HttpResponse.Json(new { success = true, typeName = type.FullName, methodName = method.Name, returnValue, returnType }) + : InvokeError($"Invoke failed: {error}"); + } + catch (ArgumentException ex) + { + return InvokeError($"Argument error: {ex.Message}"); + } + } + + private async Task HandleElementInvoke(HttpRequest request) + { + if (_app == null) return InvokeError("Agent not bound to app"); + if (!request.RouteParams.TryGetValue("id", out var id)) + return InvokeError("Element ID required"); + + var body = request.BodyAs(); + if (body?.MethodName == null) + return InvokeError("methodName is required"); + + var result = await DispatchAsync(() => + { + var el = _treeWalker.GetElementById(id, _app); + if (el == null) return (found: false, success: false, returnValue: (string?)null, returnType: (string?)null, error: (string?)$"Element '{id}' not found"); + + var type = el.GetType(); + var method = type.GetMethod(body.MethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (method == null) + return (found: false, success: false, returnValue: (string?)null, returnType: (string?)null, error: (string?)$"Method '{body.MethodName}' not found on element type '{type.Name}'"); + + try + { + var convertedArgs = ConvertInvokeArgs(method.GetParameters(), body.Args); + var invokeResult = method.Invoke(el, convertedArgs); + + if (invokeResult is Task) + return (found: true, success: true, returnValue: (string?)null, returnType: (string?)"Task", error: (string?)"ASYNC_NEEDS_AWAIT"); + + if (method.ReturnType == typeof(void)) + return (found: true, success: true, returnValue: (string?)null, returnType: (string?)"void", error: (string?)null); + + return (found: true, success: true, returnValue: FormatPropertyValue(invokeResult), returnType: (string?)FormatParameterTypeName(method.ReturnType), error: (string?)null); + } + catch (TargetInvocationException tie) + { + var inner = tie.InnerException ?? tie; + return (found: true, success: false, returnValue: (string?)null, returnType: (string?)null, error: (string?)$"{inner.GetType().Name}: {inner.Message}"); + } + catch (ArgumentException ex) + { + return (found: true, success: false, returnValue: (string?)null, returnType: (string?)null, error: (string?)$"Argument error: {ex.Message}"); + } + }); + + // Handle async methods that need to be awaited off the UI thread + if (result.error == "ASYNC_NEEDS_AWAIT") + { + try + { + var el = await DispatchAsync(() => _treeWalker.GetElementById(id, _app!)); + if (el == null) return InvokeError($"Element '{id}' not found"); + + var type = el.GetType(); + var method = type.GetMethod(body.MethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)!; + var convertedArgs = ConvertInvokeArgs(method.GetParameters(), body.Args); + var invokeTask = await DispatchAsync(() => InvokeMethodAsync(method, el, convertedArgs)); + var (success, returnValue, returnType, error) = await invokeTask; + + return success + ? HttpResponse.Json(new { success = true, elementId = id, methodName = body.MethodName, returnValue, returnType }) + : InvokeError($"Element invoke failed: {error}"); + } + catch (Exception ex) + { + return InvokeError($"Element invoke failed: {ex.Message}"); + } + } + + if (!result.found) + return InvokeError(result.error ?? "Not found"); + + return result.success + ? HttpResponse.Json(new { success = true, elementId = id, methodName = body.MethodName, returnValue = result.returnValue, returnType = result.returnType }) + : InvokeError(result.error ?? "Invoke failed"); + } + + private Task HandleListMethods(HttpRequest request) + { + if (!request.QueryParams.TryGetValue("typeName", out var typeName) || string.IsNullOrWhiteSpace(typeName)) + return Task.FromResult(HttpResponse.Error("Query parameter 'typeName' is required")); + + var type = ResolveType(typeName); + if (type == null) + return Task.FromResult(HttpResponse.NotFound($"Type '{typeName}' not found in loaded assemblies.")); + + var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(m => !m.IsSpecialName) // exclude property getters/setters, event add/remove + .Select(m => + { + var actionAttr = m.GetCustomAttribute(); + return new + { + name = m.Name, + returnType = FormatParameterTypeName(m.ReturnType), + isStatic = m.IsStatic, + isAsync = typeof(Task).IsAssignableFrom(m.ReturnType), + devFlowActionName = actionAttr?.Name, + parameters = m.GetParameters().Select(p => new + { + name = p.Name, + type = FormatParameterTypeName(p.ParameterType), + description = p.GetCustomAttribute()?.Description, + defaultValue = p.HasDefaultValue ? FormatDefaultValue(p.DefaultValue) : null, + isRequired = !p.HasDefaultValue + }) + }; + }); + + return Task.FromResult(HttpResponse.Json(new { typeName = type.FullName, methods })); + } + + #endregion + + #region DTOs + + private class InvokeActionEntry + { + public string Name { get; set; } = ""; + public string? Description { get; set; } + public string DeclaringType { get; set; } = ""; + public MethodInfo Method { get; set; } = null!; + public InvokeParameterInfo[] Parameters { get; set; } = []; + } + + private class InvokeParameterInfo + { + public string Name { get; set; } = ""; + public string Type { get; set; } = ""; + public string? Description { get; set; } + public string? DefaultValue { get; set; } + public bool IsRequired { get; set; } + } + + #endregion +} + +public class InvokeRequest +{ + public string? TypeName { get; set; } + public string? MethodName { get; set; } + public JsonElement[]? Args { get; set; } + public string? Resolve { get; set; } +} + +public class InvokeActionRequest +{ + public JsonElement[]? Args { get; set; } +} + +public class ElementInvokeRequest +{ + public string? MethodName { get; set; } + public JsonElement[]? Args { get; set; } +} diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs index 253f0405e..37b823d88 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs @@ -22,7 +22,7 @@ namespace Microsoft.Maui.DevFlow.Agent.Core; /// The main agent service that hosts the HTTP API and coordinates /// visual tree inspection and element interactions. /// -public class DevFlowAgentService : IDisposable, IMarkerPublisher +public partial class DevFlowAgentService : IDisposable, IMarkerPublisher { private readonly AgentOptions _options; private readonly AgentHttpServer _server; @@ -552,6 +552,13 @@ private void RegisterRoutes() _server.MapGet("/api/v1/storage/files/{path}", HandleFileDownload); _server.MapPut("/api/v1/storage/files/{path}", HandleFileUpload); _server.MapDelete("/api/v1/storage/files/{path}", HandleFileDelete); + + // Invoke / reflection + _server.MapGet("/api/v1/invoke/actions", HandleListActions); + _server.MapPost("/api/v1/invoke/actions/{name}", HandleInvokeAction); + _server.MapPost("/api/v1/invoke", HandleInvoke); + _server.MapGet("/api/v1/invoke/methods", HandleListMethods); + _server.MapPost("/api/v1/ui/elements/{id}/invoke", HandleElementInvoke); } private async Task HandleStatus(HttpRequest request) @@ -700,6 +707,11 @@ private Task HandleCapabilities(HttpRequest request) features = IsJobsSupported ? IsJobRunSupported ? new[] { "list", "run" } : new[] { "list" } : Array.Empty() + }, + invoke = new + { + supported = true, + features = new[] { "actions", "static", "service", "element", "discover" } } }; @@ -2013,6 +2025,12 @@ private sealed class BatchActionRequest public string? Direction { get; set; } public double Distance { get; set; } = 120; public int DurationMs { get; set; } = 200; + // Invoke-related properties + public string? TypeName { get; set; } + public string? MethodName { get; set; } + public JsonElement[]? Args { get; set; } + public string? Resolve { get; set; } + public string? Name { get; set; } } private async Task HandleBack(HttpRequest request) @@ -2292,6 +2310,31 @@ private async Task HandleBatch(HttpRequest request) Body = JsonSerializer.Serialize(new SetPropertyRequest { Value = action.Value ?? string.Empty }) }); break; + case "invoke": + response = await HandleInvoke(new HttpRequest + { + Method = "POST", + Body = JsonSerializer.Serialize(new InvokeRequest + { + TypeName = action.TypeName, + MethodName = action.MethodName, + Args = action.Args, + Resolve = action.Resolve + }) + }); + break; + case "invoke-action": + case "invoke_action": + response = await HandleInvokeAction(new HttpRequest + { + Method = "POST", + RouteParams = new Dictionary + { + ["name"] = action.Name ?? string.Empty + }, + Body = JsonSerializer.Serialize(new InvokeActionRequest { Args = action.Args }) + }); + break; default: response = HttpResponse.Error($"Unsupported batch action '{actionName}'"); break; diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/Microsoft.Maui.DevFlow.Agent.Core.csproj b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/Microsoft.Maui.DevFlow.Agent.Core.csproj index 0d4e562ed..6615da0f0 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/Microsoft.Maui.DevFlow.Agent.Core.csproj +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/Microsoft.Maui.DevFlow.Agent.Core.csproj @@ -19,6 +19,9 @@ + diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Shipped.md b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 000000000..36b38dd26 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,10 @@ +## Release 0.1.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +MAUI_DFA001 | DevFlow | Error | Unsupported parameter type for [DevFlowAction] +MAUI_DFA002 | DevFlow | Error | [DevFlowAction] method must be public static +MAUI_DFA003 | DevFlow | Warning | Return type may not serialize cleanly +MAUI_DFA004 | DevFlow | Info | Parameter missing [Description] attribute diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 000000000..bcdf8216a --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1 @@ +; No unshipped changes diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/DevFlowActionAnalyzer.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/DevFlowActionAnalyzer.cs new file mode 100644 index 000000000..88519ba08 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/DevFlowActionAnalyzer.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.Maui.DevFlow.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DevFlowActionAnalyzer : DiagnosticAnalyzer +{ + private const string DevFlowActionAttributeName = "DevFlowActionAttribute"; + private const string DevFlowActionAttributeShortName = "DevFlowAction"; + private const string DescriptionAttributeName = "DescriptionAttribute"; + + // MAUI_DFA001: Unsupported parameter type + private static readonly DiagnosticDescriptor UnsupportedParameterType = new( + id: "MAUI_DFA001", + title: "Unsupported parameter type for [DevFlowAction]", + messageFormat: "Parameter '{0}' has unsupported type '{1}' — use string, bool, int, long, short, byte, float, double, decimal, enum, or arrays/lists of these types", + category: "DevFlow", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "DevFlow Actions can only accept parameters of types that can be reliably deserialized from JSON: primitive types, enums, and collections of these."); + + // MAUI_DFA002: Must be public static + private static readonly DiagnosticDescriptor MustBePublicStatic = new( + id: "MAUI_DFA002", + title: "[DevFlowAction] method must be public static", + messageFormat: "Method '{0}' must be 'public static' to be a DevFlow Action", + category: "DevFlow", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "DevFlow Actions are invoked via reflection and must be public static methods."); + + // MAUI_DFA003: Return type warning + private static readonly DiagnosticDescriptor ReturnTypeMayNotSerialize = new( + id: "MAUI_DFA003", + title: "Return type may not serialize cleanly", + messageFormat: "Return type '{0}' may not serialize cleanly — prefer void, Task, Task with a simple type, or string", + category: "DevFlow", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "DevFlow Action return values are serialized to JSON. Complex types may lose fidelity."); + + // MAUI_DFA004: Missing [Description] on parameter + private static readonly DiagnosticDescriptor MissingParameterDescription = new( + id: "MAUI_DFA004", + title: "Parameter missing [Description] attribute", + messageFormat: "Parameter '{0}' has no [Description] attribute — adding a description helps AI agents understand how to use this action", + category: "DevFlow", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "AI agents rely on parameter descriptions to understand what values to pass. Adding [Description] makes your action more usable."); + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(UnsupportedParameterType, MustBePublicStatic, ReturnTypeMayNotSerialize, MissingParameterDescription); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method); + } + + private static void AnalyzeMethod(SymbolAnalysisContext context) + { + var method = (IMethodSymbol)context.Symbol; + + if (!HasDevFlowActionAttribute(method)) + return; + + // DFA002: Must be public static + if (method.DeclaredAccessibility != Accessibility.Public || !method.IsStatic) + { + context.ReportDiagnostic(Diagnostic.Create( + MustBePublicStatic, + method.Locations.FirstOrDefault(), + method.Name)); + } + + // DFA001 + DFA004: Check parameters + foreach (var param in method.Parameters) + { + if (!IsSupportedType(param.Type)) + { + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedParameterType, + param.Locations.FirstOrDefault(), + param.Name, + param.Type.ToDisplayString())); + } + + if (!HasDescriptionAttribute(param)) + { + context.ReportDiagnostic(Diagnostic.Create( + MissingParameterDescription, + param.Locations.FirstOrDefault(), + param.Name)); + } + } + + // DFA003: Check return type + var returnType = method.ReturnType; + if (!IsWellKnownReturnType(returnType)) + { + context.ReportDiagnostic(Diagnostic.Create( + ReturnTypeMayNotSerialize, + method.Locations.FirstOrDefault(), + returnType.ToDisplayString())); + } + } + + private static bool HasDevFlowActionAttribute(IMethodSymbol method) + { + return method.GetAttributes().Any(attr => + { + var name = attr.AttributeClass?.Name; + return name == DevFlowActionAttributeName || name == DevFlowActionAttributeShortName; + }); + } + + private static bool HasDescriptionAttribute(IParameterSymbol param) + { + return param.GetAttributes().Any(attr => + attr.AttributeClass?.Name == DescriptionAttributeName || + attr.AttributeClass?.Name == "Description"); + } + + private static bool IsSupportedType(ITypeSymbol type) + { + // Handle nullable value types: Nullable + if (type is INamedTypeSymbol namedType && + namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + namedType.TypeArguments.Length == 1) + { + return IsSupportedSimpleType(namedType.TypeArguments[0]); + } + + // Handle arrays: T[] + if (type is IArrayTypeSymbol arrayType) + { + return IsSupportedSimpleType(arrayType.ElementType); + } + + // Handle generic collections: List, IList, IEnumerable, IReadOnlyList + if (type is INamedTypeSymbol genericType && genericType.IsGenericType && genericType.TypeArguments.Length == 1) + { + var def = genericType.OriginalDefinition.ToDisplayString(); + if (def == "System.Collections.Generic.List" || + def == "System.Collections.Generic.IList" || + def == "System.Collections.Generic.IEnumerable" || + def == "System.Collections.Generic.IReadOnlyList" || + def == "System.Collections.Generic.ICollection" || + def == "System.Collections.Generic.IReadOnlyCollection") + { + return IsSupportedSimpleType(genericType.TypeArguments[0]); + } + } + + return IsSupportedSimpleType(type); + } + + private static bool IsSupportedSimpleType(ITypeSymbol type) + { + switch (type.SpecialType) + { + case SpecialType.System_String: + case SpecialType.System_Boolean: + case SpecialType.System_Int32: + case SpecialType.System_Int64: + case SpecialType.System_Int16: + case SpecialType.System_Byte: + case SpecialType.System_Single: + case SpecialType.System_Double: + case SpecialType.System_Decimal: + return true; + } + + if (type.TypeKind == TypeKind.Enum) + return true; + + return false; + } + + private static bool IsWellKnownReturnType(ITypeSymbol type) + { + // void + if (type.SpecialType == SpecialType.System_Void) + return true; + + // Simple supported types + if (IsSupportedSimpleType(type)) + return true; + + // string (already covered by IsSupportedSimpleType, but explicit for clarity) + if (type.SpecialType == SpecialType.System_String) + return true; + + if (type is INamedTypeSymbol namedType) + { + var fullName = namedType.ToDisplayString(); + + // Task (non-generic) + if (fullName == "System.Threading.Tasks.Task") + return true; + + // Task where T is a supported simple type or string + if (namedType.IsGenericType && + namedType.OriginalDefinition.ToDisplayString() == "System.Threading.Tasks.Task" && + namedType.TypeArguments.Length == 1) + { + var inner = namedType.TypeArguments[0]; + return IsSupportedSimpleType(inner) || inner.SpecialType == SpecialType.System_Void; + } + + // ValueTask + if (fullName == "System.Threading.Tasks.ValueTask") + return true; + + // ValueTask + if (namedType.IsGenericType && + namedType.OriginalDefinition.ToDisplayString() == "System.Threading.Tasks.ValueTask" && + namedType.TypeArguments.Length == 1) + { + var inner = namedType.TypeArguments[0]; + return IsSupportedSimpleType(inner); + } + } + + return false; + } +} diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/Microsoft.Maui.DevFlow.Analyzers.csproj b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/Microsoft.Maui.DevFlow.Analyzers.csproj new file mode 100644 index 000000000..6cf45b35e --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/Microsoft.Maui.DevFlow.Analyzers.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + latest + false + false + true + + + + + + + + + + + + diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs index 0eb002cdb..a90d77d77 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs @@ -558,6 +558,64 @@ private async Task DeleteActionAsync(string path) } } + // ── Invoke / Reflection ── + + private const string InvokeApi = $"{ApiV1}/invoke"; + + /// + /// List all registered DevFlow Actions (methods annotated with [DevFlowAction]). + /// + public async Task ListActionsAsync() + => await GetJsonAsync($"{InvokeApi}/actions"); + + /// + /// Invoke a registered DevFlow Action by name. + /// + public async Task InvokeActionAsync(string actionName, JsonArray? args = null) + { + var body = new JsonObject(); + if (args != null) + body["args"] = args; + return await PostJsonAsync($"{InvokeApi}/actions/{Uri.EscapeDataString(actionName)}", body); + } + + /// + /// Invoke a method by type name and method name via reflection. + /// + public async Task InvokeAsync(string typeName, string methodName, JsonArray? args = null, string? resolve = null) + { + var body = new JsonObject + { + ["typeName"] = typeName, + ["methodName"] = methodName + }; + if (args != null) + body["args"] = args; + if (resolve != null) + body["resolve"] = resolve; + return await PostJsonAsync($"{InvokeApi}", body); + } + + /// + /// Invoke a method on a visual tree element. + /// + public async Task InvokeElementMethodAsync(string elementId, string methodName, JsonArray? args = null) + { + var body = new JsonObject + { + ["methodName"] = methodName + }; + if (args != null) + body["args"] = args; + return await PostJsonAsync($"{UiApi}/elements/{elementId}/invoke", body); + } + + /// + /// Discover public methods on a type. + /// + public async Task ListMethodsAsync(string typeName) + => await GetJsonAsync($"{InvokeApi}/methods?typeName={Uri.EscapeDataString(typeName)}"); + // ── Preferences ── public async Task GetPreferencesAsync(string? sharedName = null) @@ -1168,3 +1226,26 @@ public class ProfilerCapabilities [System.Text.Json.Serialization.JsonPropertyName("threadCountSupported")] public bool ThreadCountSupported { get; set; } } + +/// +/// Result of an invoke operation. +/// +public class InvokeResult +{ + [System.Text.Json.Serialization.JsonPropertyName("success")] + public bool Success { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("returnValue")] + public string? ReturnValue { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("returnType")] + public string? ReturnType { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string? Error { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("action")] + public string? Action { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("typeName")] + public string? TypeName { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("methodName")] + public string? MethodName { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("elementId")] + public string? ElementId { get; set; } +} diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Driver/DevFlowDriverJson.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Driver/DevFlowDriverJson.cs index e70db1320..6fc7ea12e 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Driver/DevFlowDriverJson.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Driver/DevFlowDriverJson.cs @@ -18,6 +18,7 @@ namespace Microsoft.Maui.DevFlow.Driver; [JsonSerializable(typeof(RecordingState))] [JsonSerializable(typeof(AgentClient.ProfilerSessionEnvelope))] [JsonSerializable(typeof(AgentClient.ActionResponse))] +[JsonSerializable(typeof(InvokeResult))] internal sealed partial class DevFlowDriverJsonContext : JsonSerializerContext; internal static class DriverJson diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs new file mode 100644 index 000000000..ef72a7019 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -0,0 +1,318 @@ +using System.ComponentModel; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using Microsoft.Maui; +using Microsoft.Maui.Controls; +using Microsoft.Maui.DevFlow.Agent.Core; +using Microsoft.Maui.DevFlow.Driver; +using Microsoft.Maui.Dispatching; + +namespace Microsoft.Maui.DevFlow.Tests; + +public class InvokeTests +{ + [Fact] + public async Task ListActions_DiscoversMethods_WithDevFlowActionAttribute() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var actions = await harness.Client.ListActionsAsync(); + + var json = actions; + Assert.Equal(JsonValueKind.Object, json.ValueKind); + + var actionsArray = json.GetProperty("actions"); + Assert.Equal(JsonValueKind.Array, actionsArray.ValueKind); + + // Find our test action + var testAction = actionsArray.EnumerateArray() + .FirstOrDefault(a => a.GetProperty("name").GetString() == "test-greet"); + Assert.NotEqual(default, testAction); + Assert.Equal("Returns a greeting for the given name", testAction.GetProperty("description").GetString()); + + // Verify parameter metadata + var parameters = testAction.GetProperty("parameters"); + Assert.Equal(JsonValueKind.Array, parameters.ValueKind); + + var nameParam = parameters.EnumerateArray().First(); + Assert.Equal("name", nameParam.GetProperty("name").GetString()); + Assert.Equal("string", nameParam.GetProperty("type").GetString()); + Assert.Equal("The name to greet", nameParam.GetProperty("description").GetString()); + } + + [Fact] + public async Task InvokeAction_CallsRegisteredAction_ReturnsResult() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeActionAsync("test-greet", + JsonArray(JsonElement("World"))); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("Hello, World!", result.ReturnValue); + } + + [Fact] + public async Task InvokeAction_WithDefaultParameters_UsesDefaults() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeActionAsync("test-greet"); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("Hello, Friend!", result.ReturnValue); + } + + [Fact] + public async Task Invoke_CallsStaticMethod_ByTypeName() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.Add), + JsonArray(JsonElement(3), JsonElement(4))); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("7", result.ReturnValue); + } + + [Fact] + public async Task Invoke_CallsAsyncMethod_AwaitsResult() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.GetValueAsync), + JsonArray(JsonElement("test-value"))); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("async:test-value", result.ReturnValue); + } + + [Fact] + public async Task Invoke_CallsVoidMethod_ReturnsOk() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + TestInvokeHelpers.LastSideEffect = null; + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.DoSideEffect), + JsonArray(JsonElement("done"))); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("done", TestInvokeHelpers.LastSideEffect); + } + + [Fact] + public async Task Invoke_WithBoolParameter_ConvertsCorrectly() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.IsEnabled), + JsonArray(JsonElement(true))); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("True", result.ReturnValue); + } + + [Fact] + public async Task Invoke_MethodNotFound_ReturnsError() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + "NonExistentMethod"); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Invoke_TypeNotFound_ReturnsError() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + "Some.Nonexistent.Type", + "SomeMethod"); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ListMethods_ReturnsPublicMethods_ForType() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.ListMethodsAsync(typeof(TestInvokeHelpers).FullName!); + + Assert.NotEqual(default, result); + Assert.Equal(JsonValueKind.Object, result.ValueKind); + + var methods = result.GetProperty("methods"); + Assert.Equal(JsonValueKind.Array, methods.ValueKind); + + var methodNames = methods.EnumerateArray() + .Select(m => m.GetProperty("name").GetString()) + .ToList(); + + Assert.Contains("Greet", methodNames); + Assert.Contains("Add", methodNames); + Assert.Contains("GetValueAsync", methodNames); + Assert.Contains("DoSideEffect", methodNames); + } + + [Fact] + public async Task InvokeAction_NotFound_ReturnsError() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeActionAsync("nonexistent-action"); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + #region Helpers + + private static System.Text.Json.Nodes.JsonArray JsonArray(params JsonElement[] elements) + { + var arr = new System.Text.Json.Nodes.JsonArray(); + foreach (var e in elements) + arr.Add(System.Text.Json.Nodes.JsonNode.Parse(e.GetRawText())); + return arr; + } + + private static JsonElement JsonElement(object value) + { + var json = JsonSerializer.Serialize(value); + return JsonDocument.Parse(json).RootElement.Clone(); + } + + #endregion + + #region Test Harness + + private sealed class InvokeTestHarness : IDisposable + { + private readonly DevFlowAgentService _service; + public AgentClient Client { get; } + + private InvokeTestHarness(DevFlowAgentService service, AgentClient client) + { + _service = service; + Client = client; + } + + public static async Task CreateAsync() + { + var app = new TestApplication([]); + var service = new DevFlowAgentService(new AgentOptions { Port = GetFreePort() }); + var client = new AgentClient("localhost", service.Port); + + service.StartServerOnly(new ImmediateDispatcher()); + service.BindApp(app); + + for (var i = 0; i < 10; i++) + { + var status = await client.GetStatusAsync(); + if (status != null) + return new InvokeTestHarness(service, client); + await Task.Delay(100); + } + + throw new InvalidOperationException("Agent did not start in time"); + } + + public void Dispose() + { + Client.Dispose(); + _service.Dispose(); + } + + private static int GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + } + + private sealed class ImmediateDispatcher : IDispatcher + { + public bool IsDispatchRequired => false; + public bool Dispatch(Action action) { action(); return true; } + public bool DispatchDelayed(TimeSpan delay, Action action) { action(); return true; } + public IDispatcherTimer CreateTimer() => new ImmediateDispatcherTimer(); + } + + private sealed class ImmediateDispatcherTimer : IDispatcherTimer + { + public bool IsRepeating { get; set; } + public TimeSpan Interval { get; set; } + public bool IsRunning { get; private set; } + public event EventHandler? Tick { add { } remove { } } + public void Start() => IsRunning = true; + public void Stop() => IsRunning = false; + } + + private sealed class TestApplication : Application, IVisualTreeElement + { + private readonly IReadOnlyList _children; + public TestApplication(IEnumerable views) => _children = views.Cast().ToArray(); + IReadOnlyList IVisualTreeElement.GetVisualChildren() => _children; + IVisualTreeElement? IVisualTreeElement.GetVisualParent() => null; + } + + #endregion +} + +#region Test Fixture Classes + +/// +/// Test helper class with [DevFlowAction]-annotated methods for invoke tests. +/// These methods are discovered via assembly scanning during tests. +/// +public static class TestInvokeHelpers +{ + public static string? LastSideEffect { get; set; } + + [DevFlowAction("test-greet", Description = "Returns a greeting for the given name")] + public static string Greet( + [Description("The name to greet")] string name = "Friend") + => $"Hello, {name}!"; + + [DevFlowAction("test-add", Description = "Adds two numbers")] + public static int Add( + [Description("First number")] int a, + [Description("Second number")] int b) + => a + b; + + public static Task GetValueAsync(string key) + => Task.FromResult($"async:{key}"); + + public static void DoSideEffect(string value) + => LastSideEffect = value; + + public static string IsEnabled(bool enabled) + => enabled.ToString(); +} + +#endregion From 35638ac1a1d9581c7c36787504c9f6ee4e16b02e Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:18:06 -0400 Subject: [PATCH 02/24] fix: Use Nullable.GetUnderlyingType for IsRequired parameter check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 78 +++++++------------ 1 file changed, 26 insertions(+), 52 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index a777a4807..1afef3a90 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -72,7 +72,7 @@ private static InvokeParameterInfo[] BuildParameterInfoList(MethodInfo method) Type = FormatParameterTypeName(p.ParameterType), Description = p.GetCustomAttribute()?.Description, DefaultValue = p.HasDefaultValue ? FormatDefaultValue(p.DefaultValue) : null, - IsRequired = !p.HasDefaultValue && !p.ParameterType.IsAssignableTo(typeof(Nullable<>)) + IsRequired = !p.HasDefaultValue && Nullable.GetUnderlyingType(p.ParameterType) == null }).ToArray(); } @@ -473,70 +473,44 @@ private async Task HandleElementInvoke(HttpRequest request) if (body?.MethodName == null) return InvokeError("methodName is required"); - var result = await DispatchAsync(() => + // Resolve element and method on the UI thread + var resolution = await DispatchAsync(() => { var el = _treeWalker.GetElementById(id, _app); - if (el == null) return (found: false, success: false, returnValue: (string?)null, returnType: (string?)null, error: (string?)$"Element '{id}' not found"); + if (el == null) + return (element: (object?)null, method: (MethodInfo?)null, error: (string?)$"Element '{id}' not found"); var type = el.GetType(); var method = type.GetMethod(body.MethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); if (method == null) - return (found: false, success: false, returnValue: (string?)null, returnType: (string?)null, error: (string?)$"Method '{body.MethodName}' not found on element type '{type.Name}'"); + return (element: (object?)null, method: (MethodInfo?)null, error: (string?)$"Method '{body.MethodName}' not found on element type '{type.Name}'"); - try - { - var convertedArgs = ConvertInvokeArgs(method.GetParameters(), body.Args); - var invokeResult = method.Invoke(el, convertedArgs); + return (element: (object?)el, method: (MethodInfo?)method, error: (string?)null); + }); - if (invokeResult is Task) - return (found: true, success: true, returnValue: (string?)null, returnType: (string?)"Task", error: (string?)"ASYNC_NEEDS_AWAIT"); + if (resolution.error != null) + return InvokeError(resolution.error); - if (method.ReturnType == typeof(void)) - return (found: true, success: true, returnValue: (string?)null, returnType: (string?)"void", error: (string?)null); + try + { + var convertedArgs = ConvertInvokeArgs(resolution.method!.GetParameters(), body.Args); - return (found: true, success: true, returnValue: FormatPropertyValue(invokeResult), returnType: (string?)FormatParameterTypeName(method.ReturnType), error: (string?)null); - } - catch (TargetInvocationException tie) - { - var inner = tie.InnerException ?? tie; - return (found: true, success: false, returnValue: (string?)null, returnType: (string?)null, error: (string?)$"{inner.GetType().Name}: {inner.Message}"); - } - catch (ArgumentException ex) - { - return (found: true, success: false, returnValue: (string?)null, returnType: (string?)null, error: (string?)$"Argument error: {ex.Message}"); - } - }); + // Invoke on the UI thread and await the result (handles both sync and async methods) + var invokeTask = await DispatchAsync(() => InvokeMethodAsync(resolution.method!, resolution.element, convertedArgs)); + var (success, returnValue, returnType, error) = await invokeTask; - // Handle async methods that need to be awaited off the UI thread - if (result.error == "ASYNC_NEEDS_AWAIT") + return success + ? HttpResponse.Json(new { success = true, elementId = id, methodName = body.MethodName, returnValue, returnType }) + : InvokeError($"Element invoke failed: {error}"); + } + catch (ArgumentException ex) { - try - { - var el = await DispatchAsync(() => _treeWalker.GetElementById(id, _app!)); - if (el == null) return InvokeError($"Element '{id}' not found"); - - var type = el.GetType(); - var method = type.GetMethod(body.MethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)!; - var convertedArgs = ConvertInvokeArgs(method.GetParameters(), body.Args); - var invokeTask = await DispatchAsync(() => InvokeMethodAsync(method, el, convertedArgs)); - var (success, returnValue, returnType, error) = await invokeTask; - - return success - ? HttpResponse.Json(new { success = true, elementId = id, methodName = body.MethodName, returnValue, returnType }) - : InvokeError($"Element invoke failed: {error}"); - } - catch (Exception ex) - { - return InvokeError($"Element invoke failed: {ex.Message}"); - } + return InvokeError($"Argument error: {ex.Message}"); + } + catch (Exception ex) + { + return InvokeError($"Element invoke failed: {ex.Message}"); } - - if (!result.found) - return InvokeError(result.error ?? "Not found"); - - return result.success - ? HttpResponse.Json(new { success = true, elementId = id, methodName = body.MethodName, returnValue = result.returnValue, returnType = result.returnType }) - : InvokeError(result.error ?? "Invoke failed"); } private Task HandleListMethods(HttpRequest request) From 4648fb58fde2e00080231b90334d75095b2b9c23 Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:18:37 -0400 Subject: [PATCH 03/24] fix: Avoid double-invocation of async element methods in invoke handler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 1afef3a90..a450a7e69 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -404,24 +404,37 @@ private async Task HandleInvoke(HttpRequest request) var bindingFlags = BindingFlags.Public | BindingFlags.IgnoreCase | (isService ? BindingFlags.Instance : BindingFlags.Static); - var method = type.GetMethod(body.MethodName, bindingFlags); - if (method == null) - { - // Try finding by parameter count for overload resolution - var candidates = type.GetMethods(bindingFlags) - .Where(m => string.Equals(m.Name, body.MethodName, StringComparison.OrdinalIgnoreCase)) - .ToArray(); + // Always enumerate methods by name to avoid AmbiguousMatchException on overloads + var candidates = type.GetMethods(bindingFlags) + .Where(m => string.Equals(m.Name, body.MethodName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); - if (candidates.Length == 0) - return InvokeError($"Method '{body.MethodName}' not found on type '{type.FullName}'."); + if (candidates.Length == 0) + return InvokeError($"Method '{body.MethodName}' not found on type '{type.FullName}'."); + MethodInfo method; + if (candidates.Length == 1) + { + method = candidates[0]; + } + else + { var argCount = body.Args?.Length ?? 0; - method = candidates.FirstOrDefault(m => + var matched = candidates.FirstOrDefault(m => { var ps = m.GetParameters(); var required = ps.Count(p => !p.HasDefaultValue); return argCount >= required && argCount <= ps.Length; - }) ?? candidates[0]; + }); + + if (matched == null) + { + var signatures = string.Join(", ", candidates.Select(m => + $"{m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})")); + return InvokeError($"Ambiguous method '{body.MethodName}' on type '{type.FullName}'. Candidates: {signatures}"); + } + + method = matched; } object? target = null; From ff2eb887d8c1b8958f2619bc06a9e8a8275b1b64 Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:19:31 -0400 Subject: [PATCH 04/24] fix: Detect and deduplicate shadowed DevFlowAction names at discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index a450a7e69..5cf7de40e 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -47,7 +47,25 @@ private InvokeActionEntry[] DiscoverActions() } } - _cachedActions = actions.ToArray(); + // Detect and deduplicate shadowed action names (keep first occurrence) + var duplicates = actions + .GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1); + + foreach (var group in duplicates) + { + var shadowed = group.Skip(1); + foreach (var dup in shadowed) + { + System.Diagnostics.Debug.WriteLine( + $"[Microsoft.Maui.DevFlow] Warning: Duplicate DevFlowAction name '{group.Key}' on {dup.DeclaringType}.{dup.Method.Name} shadows the first registration on {group.First().DeclaringType}.{group.First().Method.Name}. The duplicate will be ignored."); + } + } + + _cachedActions = actions + .GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToArray(); return _cachedActions; } @@ -292,6 +310,26 @@ private static string FormatParameterTypeName(Type type) { var result = method.Invoke(target, args); + // Handle ValueTask (struct — does not inherit from Task) + if (result is ValueTask vt) + { + await vt; + return (true, null, "void", null); + } + + // Handle ValueTask via reflection (generic struct) + var resultType = result?.GetType(); + if (resultType != null && resultType.IsGenericType && resultType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + var asTaskMethod = resultType.GetMethod("AsTask"); + var task2 = (Task)asTaskMethod!.Invoke(result, null)!; + await task2; + var resultProp = task2.GetType().GetProperty("Result"); + var taskResult = resultProp?.GetValue(task2); + var innerType = resultType.GetGenericArguments()[0]; + return (true, taskResult != null ? FormatPropertyValue(taskResult) : null, FormatParameterTypeName(innerType), null); + } + // Handle async methods if (result is Task task) { From 4c7d72ed18f6996bb82138999b3202a5335593f2 Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:20:06 -0400 Subject: [PATCH 05/24] fix: Handle ValueTask and ValueTask returns in invoke method Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 5cf7de40e..f9132d923 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -7,16 +7,15 @@ namespace Microsoft.Maui.DevFlow.Agent.Core; // Invoke / reflection endpoints public partial class DevFlowAgentService { - private InvokeActionEntry[]? _cachedActions; + private readonly Lazy _cachedActions = new(ScanActions, LazyThreadSafetyMode.ExecutionAndPublication); private readonly Dictionary _typeResolutionCache = new(StringComparer.OrdinalIgnoreCase); #region Action Discovery - private InvokeActionEntry[] DiscoverActions() - { - if (_cachedActions != null) - return _cachedActions; + private InvokeActionEntry[] DiscoverActions() => _cachedActions.Value; + private static InvokeActionEntry[] ScanActions() + { var actions = new List(); foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) @@ -62,11 +61,10 @@ private InvokeActionEntry[] DiscoverActions() } } - _cachedActions = actions + return actions .GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) .ToArray(); - return _cachedActions; } private static bool IsFrameworkAssembly(Assembly asm) From 95663b46654aeaaa61dd4f0006eb9b522f41aaf5 Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:20:36 -0400 Subject: [PATCH 06/24] fix: Use Lazy for thread-safe action discovery cache Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index f9132d923..b96bef54b 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.ComponentModel; using System.Reflection; using System.Text.Json; @@ -8,7 +9,7 @@ namespace Microsoft.Maui.DevFlow.Agent.Core; public partial class DevFlowAgentService { private readonly Lazy _cachedActions = new(ScanActions, LazyThreadSafetyMode.ExecutionAndPublication); - private readonly Dictionary _typeResolutionCache = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _typeResolutionCache = new(StringComparer.OrdinalIgnoreCase); #region Action Discovery @@ -144,7 +145,7 @@ private static string FormatParameterTypeName(Type type) var type = Type.GetType(typeName); if (type != null) { - _typeResolutionCache[typeName] = type; + _typeResolutionCache.TryAdd(typeName, type); return type; } @@ -158,7 +159,7 @@ private static string FormatParameterTypeName(Type type) type = asm.GetType(typeName, throwOnError: false, ignoreCase: true); if (type != null) { - _typeResolutionCache[typeName] = type; + _typeResolutionCache.TryAdd(typeName, type); return type; } @@ -182,7 +183,7 @@ private static string FormatParameterTypeName(Type type) } if (bestMatch != null) - _typeResolutionCache[typeName] = bestMatch; + _typeResolutionCache.TryAdd(typeName, bestMatch); return bestMatch; } From 89a5575d40a35b88ddc169d7387c98c1523c3b79 Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:21:07 -0400 Subject: [PATCH 07/24] fix: Use ConcurrentDictionary for type resolution cache Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index b96bef54b..339724ead 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -244,7 +244,7 @@ private static string FormatParameterTypeName(Type type) else if (underlying.IsGenericType) { var def = underlying.GetGenericTypeDefinition(); - if (def == typeof(List<>) || def == typeof(IList<>) || def == typeof(IEnumerable<>) || def == typeof(IReadOnlyList<>)) + if (def == typeof(List<>) || def == typeof(IList<>) || def == typeof(IEnumerable<>) || def == typeof(IReadOnlyList<>) || def == typeof(ICollection<>) || def == typeof(IReadOnlyCollection<>)) elementType = underlying.GetGenericArguments()[0]; } From 1f433363b1e1acba45fc7a59a428173bb2798c3f Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:22:50 -0400 Subject: [PATCH 08/24] fix: Filter framework assemblies in ResolveType to prevent arbitrary code execution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 339724ead..df65e3116 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -145,15 +145,20 @@ private static string FormatParameterTypeName(Type type) var type = Type.GetType(typeName); if (type != null) { - _typeResolutionCache.TryAdd(typeName, type); - return type; + if (IsFrameworkAssembly(type.Assembly)) + type = null; + else + { + _typeResolutionCache.TryAdd(typeName, type); + return type; + } } // Scan loaded assemblies Type? bestMatch = null; foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { - if (asm.IsDynamic) continue; + if (asm.IsDynamic || IsFrameworkAssembly(asm)) continue; // Full name match (preferred) type = asm.GetType(typeName, throwOnError: false, ignoreCase: true); @@ -171,11 +176,7 @@ private static string FormatParameterTypeName(Type type) foreach (var t in asm.GetTypes()) { if (string.Equals(t.Name, typeName, StringComparison.OrdinalIgnoreCase)) - { - if (IsFrameworkAssembly(asm)) - continue; // prefer app types over framework types bestMatch = t; - } } } catch { } From bfc8f0b7b3074b4ee094129fa5fea8a376023145 Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:25:21 -0400 Subject: [PATCH 09/24] fix: Guard against null GetString in ConvertInvokeArg parse fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index df65e3116..991d3249f 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -214,19 +214,21 @@ private static string FormatParameterTypeName(Type type) { if (argElement.ValueKind == JsonValueKind.True || argElement.ValueKind == JsonValueKind.False) return argElement.GetBoolean(); - return bool.Parse(argElement.GetString()!); + var str = argElement.GetString() + ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}"); + return bool.Parse(str); } // Integer types - if (underlying == typeof(int)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt32() : int.Parse(argElement.GetString()!); - if (underlying == typeof(long)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt64() : long.Parse(argElement.GetString()!); - if (underlying == typeof(short)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt16() : short.Parse(argElement.GetString()!); - if (underlying == typeof(byte)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetByte() : byte.Parse(argElement.GetString()!); + if (underlying == typeof(int)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt32() : int.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); + if (underlying == typeof(long)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt64() : long.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); + if (underlying == typeof(short)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt16() : short.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); + if (underlying == typeof(byte)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetByte() : byte.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); // Floating point - if (underlying == typeof(float)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetSingle() : float.Parse(argElement.GetString()!); - if (underlying == typeof(double)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetDouble() : double.Parse(argElement.GetString()!); - if (underlying == typeof(decimal)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetDecimal() : decimal.Parse(argElement.GetString()!); + if (underlying == typeof(float)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetSingle() : float.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); + if (underlying == typeof(double)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetDouble() : double.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); + if (underlying == typeof(decimal)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetDecimal() : decimal.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); // Enums if (underlying.IsEnum) From 303e3463da0b8832f1d62a53b666c7aeff415ecf Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:26:34 -0400 Subject: [PATCH 10/24] test: Add invoke tests for array, enum, and nullable parameter conversion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InvokeTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index ef72a7019..f1f07f703 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -190,6 +190,81 @@ public async Task InvokeAction_NotFound_ReturnsError() Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Invoke_WithArrayParameter_ConvertsJsonArray() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.JoinNumbers), + JsonArray(JsonElement(new[] { 1, 2, 3 }))); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("1,2,3", result.ReturnValue); + } + + [Fact] + public async Task Invoke_WithEnumParameter_ConvertsStringToEnum() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.GetPriority), + JsonArray(JsonElement("High"))); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("High", result.ReturnValue); + } + + [Fact] + public async Task Invoke_WithEnumParameter_CaseInsensitive() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.GetPriority), + JsonArray(JsonElement("medium"))); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("Medium", result.ReturnValue); + } + + [Fact] + public async Task Invoke_WithNullableParameter_PassesValue() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.FormatNullable), + JsonArray(JsonElement(42))); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("42", result.ReturnValue); + } + + [Fact] + public async Task Invoke_WithNullableParameter_PassesNull() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.FormatNullable), + JsonArray(JsonElement(null))); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("null", result.ReturnValue); + } + #region Helpers private static System.Text.Json.Nodes.JsonArray JsonArray(params JsonElement[] elements) @@ -206,6 +281,12 @@ private static JsonElement JsonElement(object value) return JsonDocument.Parse(json).RootElement.Clone(); } + private static JsonElement JsonElement(T value) + { + var json = JsonSerializer.Serialize(value); + return JsonDocument.Parse(json).RootElement.Clone(); + } + #endregion #region Test Harness @@ -313,6 +394,22 @@ public static void DoSideEffect(string value) public static string IsEnabled(bool enabled) => enabled.ToString(); + + public static string JoinNumbers(int[] numbers) + => string.Join(",", numbers); + + public static string GetPriority(Priority p) + => p.ToString(); + + public static string FormatNullable(int? value) + => value.HasValue ? value.Value.ToString() : "null"; +} + +public enum Priority +{ + Low, + Medium, + High } #endregion From 2baf9b857869e81d617d52b8f398f2d8b6a88d2f Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:29:36 -0400 Subject: [PATCH 11/24] fix: Invalidate action cache on assembly load and hot reload Subscribe to AppDomain.AssemblyLoad to reset the cached actions when new non-framework assemblies are loaded (Blazor Hybrid lazy loading, plugin architectures). Add MetadataUpdateHandler so C# Hot Reload / Edit and Continue updates also invalidate the cache -- newly added [DevFlowAction] methods become immediately discoverable without restarting the app. The cache field is now static volatile Lazy (resettable) instead of readonly Lazy. Type resolution cache is also cleared on new assembly load since it may contain stale miss entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 43 ++++++++++++++++++- .../DevFlowAgentService.cs | 2 + 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 991d3249f..d8ad5dfa6 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -1,19 +1,58 @@ using System.Collections.Concurrent; using System.ComponentModel; using System.Reflection; +using System.Reflection.Metadata; using System.Text.Json; +[assembly: MetadataUpdateHandler(typeof(Microsoft.Maui.DevFlow.Agent.Core.DevFlowActionHotReloadHandler))] + namespace Microsoft.Maui.DevFlow.Agent.Core; +/// +/// Handles C# Hot Reload / Edit and Continue metadata updates. +/// When types are modified at runtime, this invalidates the cached +/// DevFlowAction list so newly added [DevFlowAction] methods are +/// immediately discoverable by AI agents. +/// +static class DevFlowActionHotReloadHandler +{ + /// Called by the runtime after a metadata update (Hot Reload / EnC). + internal static void UpdateApplication(Type[]? updatedTypes) + { + // Any metadata update could have added/removed/changed [DevFlowAction] methods. + // Invalidate so the next DiscoverActions() call rescans. + DevFlowAgentService.InvalidateActionCache(); + } +} + // Invoke / reflection endpoints public partial class DevFlowAgentService { - private readonly Lazy _cachedActions = new(ScanActions, LazyThreadSafetyMode.ExecutionAndPublication); + private static volatile Lazy s_cachedActions = new(ScanActions, LazyThreadSafetyMode.ExecutionAndPublication); private readonly ConcurrentDictionary _typeResolutionCache = new(StringComparer.OrdinalIgnoreCase); #region Action Discovery - private InvokeActionEntry[] DiscoverActions() => _cachedActions.Value; + private InvokeActionEntry[] DiscoverActions() => s_cachedActions.Value; + + /// + /// Invalidates the cached DevFlowAction list. The next call to + /// DiscoverActions() will rescan all loaded assemblies. + /// Called by AssemblyLoad handler and MetadataUpdateHandler (Hot Reload). + /// + internal static void InvalidateActionCache() + { + s_cachedActions = new Lazy(ScanActions, LazyThreadSafetyMode.ExecutionAndPublication); + } + + private void OnAssemblyLoaded(object? sender, AssemblyLoadEventArgs args) + { + if (args.LoadedAssembly.IsDynamic || IsFrameworkAssembly(args.LoadedAssembly)) + return; + + InvalidateActionCache(); + _typeResolutionCache.Clear(); + } private static InvokeActionEntry[] ScanActions() { diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs index 37b823d88..e15fa21f6 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs @@ -264,6 +264,7 @@ public DevFlowAgentService(AgentOptions? options = null) if (_options.EnableNetworkMonitoring) DevFlowHttp.SetStore(NetworkStore); NetworkStore.OnRequestCaptured += HandleCapturedNetworkRequest; + AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoaded; RegisterRoutes(); } @@ -4066,6 +4067,7 @@ public void Dispose() if (_disposed) return; _disposed = true; NetworkStore.OnRequestCaptured -= HandleCapturedNetworkRequest; + AppDomain.CurrentDomain.AssemblyLoad -= OnAssemblyLoaded; StopAutoUiHooks(); Sensors.Dispose(); Ble.Dispose(); From 725e56676a18ffe0e4e33ecb4b03166a91f29e05 Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:39:16 -0400 Subject: [PATCH 12/24] feat: Add DFA005 duplicate action name diagnostic and analyzer unit tests - Add MAUI_DFA005 (Warning): detects duplicate [DevFlowAction] names across the compilation using CompilationStart/End pattern - Refactor analyzer to use RegisterCompilationStartAction for duplicate tracking - Add Microsoft.CodeAnalysis.CSharp.Analyzer.Testing package - Create DevFlowActionAnalyzerTests with 11 tests covering DFA001-DFA005 - Update AnalyzerReleases.Unshipped.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 + .../AnalyzerReleases.Unshipped.md | 6 +- .../DevFlowActionAnalyzer.cs | 84 ++++- .../DevFlowActionAnalyzerTests.cs | 284 +++++++++++++++ .../InvokeTests.cs | 333 ++++++++++++++++++ .../Microsoft.Maui.DevFlow.Tests.csproj | 2 + 6 files changed, 701 insertions(+), 9 deletions(-) create mode 100644 src/DevFlow/Microsoft.Maui.DevFlow.Tests/DevFlowActionAnalyzerTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 48ce80f07..00f38edf7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -61,6 +61,7 @@ + diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md index bcdf8216a..145ab445e 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md @@ -1 +1,5 @@ -; No unshipped changes +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +MAUI_DFA005 | DevFlow | Warning | Duplicate [DevFlowAction] name diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/DevFlowActionAnalyzer.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/DevFlowActionAnalyzer.cs index 88519ba08..ea4977147 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/DevFlowActionAnalyzer.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/DevFlowActionAnalyzer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -56,23 +57,72 @@ public sealed class DevFlowActionAnalyzer : DiagnosticAnalyzer isEnabledByDefault: true, description: "AI agents rely on parameter descriptions to understand what values to pass. Adding [Description] makes your action more usable."); + // MAUI_DFA005: Duplicate action name + private static readonly DiagnosticDescriptor DuplicateActionName = new( + id: "MAUI_DFA005", + title: "Duplicate [DevFlowAction] name", + messageFormat: "Action name '{0}' is already used by '{1}' — duplicate names shadow each other at runtime", + category: "DevFlow", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Each [DevFlowAction] name must be unique. At runtime, duplicate names cause the second registration to be silently ignored.", + customTags: [WellKnownDiagnosticTags.CompilationEnd]); + public override ImmutableArray SupportedDiagnostics => - ImmutableArray.Create(UnsupportedParameterType, MustBePublicStatic, ReturnTypeMayNotSerialize, MissingParameterDescription); + ImmutableArray.Create(UnsupportedParameterType, MustBePublicStatic, ReturnTypeMayNotSerialize, MissingParameterDescription, DuplicateActionName); public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); - context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method); - } - private static void AnalyzeMethod(SymbolAnalysisContext context) - { - var method = (IMethodSymbol)context.Symbol; + context.RegisterCompilationStartAction(compilationContext => + { + var actionsByName = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); - if (!HasDevFlowActionAttribute(method)) - return; + compilationContext.RegisterSymbolAction(symbolContext => + { + var method = (IMethodSymbol)symbolContext.Symbol; + + if (!HasDevFlowActionAttribute(method)) + return; + + // Track action name for DFA005 duplicate check + var actionName = GetDevFlowActionName(method); + if (actionName != null) + { + actionsByName.GetOrAdd(actionName, _ => new ConcurrentBag()).Add(method); + } + + AnalyzeMethod(symbolContext, method); + }, SymbolKind.Method); + + compilationContext.RegisterCompilationEndAction(endContext => + { + foreach (var kvp in actionsByName) + { + var methods = kvp.Value.ToArray(); + if (methods.Length < 2) + continue; + + // Report on every method that shares the duplicate name + foreach (var method in methods) + { + var otherMethod = methods.First(m => !SymbolEqualityComparer.Default.Equals(m, method)); + var otherName = $"{otherMethod.ContainingType.Name}.{otherMethod.Name}"; + endContext.ReportDiagnostic(Diagnostic.Create( + DuplicateActionName, + method.Locations.FirstOrDefault(), + kvp.Key, + otherName)); + } + } + }); + }); + } + private static void AnalyzeMethod(SymbolAnalysisContext context, IMethodSymbol method) + { // DFA002: Must be public static if (method.DeclaredAccessibility != Accessibility.Public || !method.IsStatic) { @@ -114,6 +164,24 @@ private static void AnalyzeMethod(SymbolAnalysisContext context) } } + private static string? GetDevFlowActionName(IMethodSymbol method) + { + foreach (var attr in method.GetAttributes()) + { + var name = attr.AttributeClass?.Name; + if (name != DevFlowActionAttributeName && name != DevFlowActionAttributeShortName) + continue; + + if (attr.ConstructorArguments.Length > 0 && + attr.ConstructorArguments[0].Value is string actionName) + { + return actionName; + } + } + + return null; + } + private static bool HasDevFlowActionAttribute(IMethodSymbol method) { return method.GetAttributes().Any(attr => diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/DevFlowActionAnalyzerTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/DevFlowActionAnalyzerTests.cs new file mode 100644 index 000000000..281595765 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/DevFlowActionAnalyzerTests.cs @@ -0,0 +1,284 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.Maui.DevFlow.Analyzers; + +namespace Microsoft.Maui.DevFlow.Tests; + +public class DevFlowActionAnalyzerTests +{ + // Minimal attribute definitions appended to test source so the analyzer + // can resolve them without referencing the real Agent.Core assembly. + private const string AttributeStubs = """ + + namespace System.ComponentModel + { + [System.AttributeUsage(System.AttributeTargets.All)] + public sealed class DescriptionAttribute : System.Attribute + { + public DescriptionAttribute(string description) { } + } + } + + namespace Microsoft.Maui.DevFlow.Agent.Core + { + [System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = false)] + public sealed class DevFlowActionAttribute : System.Attribute + { + public string Name { get; } + public string? Description { get; set; } + public DevFlowActionAttribute(string name) { Name = name; } + } + } + """; + + private static CSharpAnalyzerTest CreateTest( + string source, + params DiagnosticResult[] expected) + { + var test = new CSharpAnalyzerTest + { + TestCode = source + AttributeStubs, + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + }; + test.ExpectedDiagnostics.AddRange(expected); + return test; + } + + [Fact] + public async Task ValidAction_NoDiagnostics() + { + const string source = """ + using System.ComponentModel; + using Microsoft.Maui.DevFlow.Agent.Core; + + public static class Actions + { + [DevFlowAction("my-action")] + public static void MyAction([Description("a param")] string name) { } + } + """; + + await CreateTest(source).RunAsync(); + } + + [Fact] + public async Task DFA001_UnsupportedParamType_ReportsDiagnostic() + { + const string source = """ + using System.ComponentModel; + using Microsoft.Maui.DevFlow.Agent.Core; + + public static class Actions + { + [DevFlowAction("bad-param")] + public static void BadParam([Description("an object")] object {|#0:val|}) { } + } + """; + + var expected = new DiagnosticResult("MAUI_DFA001", DiagnosticSeverity.Error) + .WithLocation(0) + .WithArguments("val", "object"); + + await CreateTest(source, expected).RunAsync(); + } + + [Fact] + public async Task DFA002_PrivateMethod_ReportsDiagnostic() + { + const string source = """ + using System.ComponentModel; + using Microsoft.Maui.DevFlow.Agent.Core; + + public static class Actions + { + [DevFlowAction("private-action")] + private static void {|#0:PrivateAction|}() { } + } + """; + + var expected = new DiagnosticResult("MAUI_DFA002", DiagnosticSeverity.Error) + .WithLocation(0) + .WithArguments("PrivateAction"); + + await CreateTest(source, expected).RunAsync(); + } + + [Fact] + public async Task DFA002_NonStaticMethod_ReportsDiagnostic() + { + const string source = """ + using System.ComponentModel; + using Microsoft.Maui.DevFlow.Agent.Core; + + public class Actions + { + [DevFlowAction("instance-action")] + public void {|#0:InstanceAction|}() { } + } + """; + + var expected = new DiagnosticResult("MAUI_DFA002", DiagnosticSeverity.Error) + .WithLocation(0) + .WithArguments("InstanceAction"); + + await CreateTest(source, expected).RunAsync(); + } + + [Fact] + public async Task DFA003_ComplexReturnType_ReportsWarning() + { + const string source = """ + using System.Collections.Generic; + using System.ComponentModel; + using Microsoft.Maui.DevFlow.Agent.Core; + + public static class Actions + { + [DevFlowAction("complex-return")] + public static List {|#0:ComplexReturn|}() => new(); + } + """; + + var expected = new DiagnosticResult("MAUI_DFA003", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("System.Collections.Generic.List"); + + await CreateTest(source, expected).RunAsync(); + } + + [Fact] + public async Task DFA004_MissingDescription_ReportsInfo() + { + const string source = """ + using Microsoft.Maui.DevFlow.Agent.Core; + + public static class Actions + { + [DevFlowAction("no-desc")] + public static void NoDesc(string {|#0:name|}) { } + } + """; + + var expected = new DiagnosticResult("MAUI_DFA004", DiagnosticSeverity.Info) + .WithLocation(0) + .WithArguments("name"); + + await CreateTest(source, expected).RunAsync(); + } + + [Fact] + public async Task DFA005_DuplicateActionName_ReportsWarning() + { + const string source = """ + using System.ComponentModel; + using Microsoft.Maui.DevFlow.Agent.Core; + + public static class Actions + { + [DevFlowAction("do-thing")] + public static void {|#0:DoThing|}([Description("x")] string x) { } + + [DevFlowAction("do-thing")] + public static void {|#1:DoThingAlso|}([Description("y")] string y) { } + } + """; + + var expected1 = new DiagnosticResult("MAUI_DFA005", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("do-thing", "Actions.DoThingAlso"); + + var expected2 = new DiagnosticResult("MAUI_DFA005", DiagnosticSeverity.Warning) + .WithLocation(1) + .WithArguments("do-thing", "Actions.DoThing"); + + await CreateTest(source, expected1, expected2).RunAsync(); + } + + [Fact] + public async Task DFA005_DuplicateAcrossClasses_ReportsWarning() + { + const string source = """ + using System.ComponentModel; + using Microsoft.Maui.DevFlow.Agent.Core; + + public static class ActionsA + { + [DevFlowAction("shared-name")] + public static void {|#0:DoA|}([Description("x")] string x) { } + } + + public static class ActionsB + { + [DevFlowAction("shared-name")] + public static void {|#1:DoB|}([Description("y")] string y) { } + } + """; + + var expected1 = new DiagnosticResult("MAUI_DFA005", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("shared-name", "ActionsB.DoB"); + + var expected2 = new DiagnosticResult("MAUI_DFA005", DiagnosticSeverity.Warning) + .WithLocation(1) + .WithArguments("shared-name", "ActionsA.DoA"); + + await CreateTest(source, expected1, expected2).RunAsync(); + } + + [Fact] + public async Task UniqueNames_NoDFA005() + { + const string source = """ + using System.ComponentModel; + using Microsoft.Maui.DevFlow.Agent.Core; + + public static class Actions + { + [DevFlowAction("action-one")] + public static void ActionOne([Description("x")] string x) { } + + [DevFlowAction("action-two")] + public static void ActionTwo([Description("y")] string y) { } + } + """; + + await CreateTest(source).RunAsync(); + } + + [Fact] + public async Task ValidAction_TaskReturn_NoDiagnostics() + { + const string source = """ + using System.ComponentModel; + using System.Threading.Tasks; + using Microsoft.Maui.DevFlow.Agent.Core; + + public static class Actions + { + [DevFlowAction("async-action")] + public static Task AsyncAction([Description("x")] string x) => Task.CompletedTask; + } + """; + + await CreateTest(source).RunAsync(); + } + + [Fact] + public async Task ValidAction_TaskOfStringReturn_NoDiagnostics() + { + const string source = """ + using System.ComponentModel; + using System.Threading.Tasks; + using Microsoft.Maui.DevFlow.Agent.Core; + + public static class Actions + { + [DevFlowAction("async-string-action")] + public static Task AsyncStringAction([Description("x")] string x) => Task.FromResult("ok"); + } + """; + + await CreateTest(source).RunAsync(); + } +} diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index f1f07f703..323cc3d0a 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -265,6 +265,307 @@ public async Task Invoke_WithNullableParameter_PassesNull() Assert.Equal("null", result.ReturnValue); } + // ── MCP-style integration tests ── + // These tests exercise the AgentClient methods using the same parameter patterns + // that the MCP InvokeTools pass (JSON string → JsonArray parsing, explicit resolve, etc.) + + [Fact] + public async Task InvokeAction_WithMcpStyleJsonArgs_ParsesAndInvokes() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + // MCP tools receive argsJson as a raw JSON string and parse it + var args = ParseMcpArgsJson("[\"World\"]"); + var result = await harness.Client.InvokeActionAsync("test-greet", args); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("Hello, World!", result.ReturnValue); + } + + [Fact] + public async Task InvokeAction_WithMcpStyleMixedTypeArgs_ParsesCorrectly() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + // MCP tools pass mixed-type args as a JSON array string + var args = ParseMcpArgsJson("[10, 20]"); + var result = await harness.Client.InvokeActionAsync("test-add", args); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("30", result.ReturnValue); + } + + [Fact] + public async Task InvokeAction_WithMcpStyleNullArgs_UsesDefaults() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + // MCP tools pass null when argsJson is empty/whitespace + var args = ParseMcpArgsJson(null); + var result = await harness.Client.InvokeActionAsync("test-greet", args); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("Hello, Friend!", result.ReturnValue); + } + + [Fact] + public async Task Invoke_WithExplicitStaticResolve_CallsStaticMethod() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + // MCP maui_invoke passes resolve: "static" explicitly + var args = ParseMcpArgsJson("[5, 7]"); + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.Add), + args, + resolve: "static"); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("12", result.ReturnValue); + } + + [Fact] + public async Task Invoke_WithServiceResolve_NoContainer_ReturnsError() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + // MCP maui_invoke with resolve: "service" when no DI container is available. + // Uses a type with an instance method to reach the DI resolution path. + var result = await harness.Client.InvokeAsync( + typeof(TestServiceClass).FullName!, + nameof(TestServiceClass.GetValue), + resolve: "service"); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("DI container", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ListMethods_WithOverloadedMethods_ReturnsAllOverloads() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.ListMethodsAsync(typeof(TestInvokeHelpersWithOverloads).FullName!); + + Assert.NotEqual(default, result); + var methods = result.GetProperty("methods"); + var concatMethods = methods.EnumerateArray() + .Where(m => m.GetProperty("name").GetString() == "Concat") + .ToList(); + + // Should list both overloads + Assert.Equal(2, concatMethods.Count); + + // Verify different parameter counts + var paramCounts = concatMethods + .Select(m => m.GetProperty("parameters").GetArrayLength()) + .OrderBy(c => c) + .ToList(); + Assert.Equal(2, paramCounts[0]); + Assert.Equal(3, paramCounts[1]); + } + + [Fact] + public async Task Invoke_OverloadedMethod_ResolvesBy2ArgCount() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + // Call Concat with 2 args — should resolve to Concat(string, string) + var args = ParseMcpArgsJson("[\"hello\", \"world\"]"); + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpersWithOverloads).FullName!, + "Concat", + args, + resolve: "static"); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("hello world", result.ReturnValue); + } + + [Fact] + public async Task Invoke_OverloadedMethod_ResolvesBy3ArgCount() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + // Call Concat with 3 args — should resolve to Concat(string, string, string) + var args = ParseMcpArgsJson("[\"a\", \"b\", \"c\"]"); + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpersWithOverloads).FullName!, + "Concat", + args, + resolve: "static"); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("a-b-c", result.ReturnValue); + } + + [Fact] + public async Task Invoke_WithMcpStyleComplexArgs_ConvertsTypes() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + // MCP tools pass all args as JSON — including booleans, numbers, strings mixed + var args = ParseMcpArgsJson("[true]"); + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.IsEnabled), + args); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("True", result.ReturnValue); + } + + [Fact] + public async Task Invoke_WithMcpStyleArrayArg_ConvertsJsonArray() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + // MCP tools pass arrays nested inside the outer args array + var args = ParseMcpArgsJson("[[1, 2, 3]]"); + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.JoinNumbers), + args); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Equal("1,2,3", result.ReturnValue); + } + + // ── Element Invoke Tests ── + + [Fact] + public async Task InvokeElement_CallsMethodOnTreeElement_ReturnsResult() + { + var view = new TestInvokeView { AutomationId = "test-invoke-view" }; + using var harness = await InvokeTestHarness.CreateAsync(view); + + var result = await harness.Client.InvokeElementMethodAsync( + "test-invoke-view", + nameof(TestInvokeView.TestMethod), + JsonArray(JsonElement("hello"))); + + Assert.NotNull(result); + Assert.True(result.Success, result.Error); + Assert.Equal("result:hello", result.ReturnValue); + Assert.Equal("test-invoke-view", result.ElementId); + } + + [Fact] + public async Task InvokeElement_WithMultipleArgs_ConvertsCorrectly() + { + var view = new TestInvokeView { AutomationId = "test-invoke-view" }; + using var harness = await InvokeTestHarness.CreateAsync(view); + + var result = await harness.Client.InvokeElementMethodAsync( + "test-invoke-view", + nameof(TestInvokeView.AddNumbers), + JsonArray(JsonElement(5), JsonElement(7))); + + Assert.NotNull(result); + Assert.True(result.Success, result.Error); + Assert.Equal("12", result.ReturnValue); + } + + [Fact] + public async Task InvokeElement_AsyncMethod_AwaitsAndReturnsResult() + { + var view = new TestInvokeView { AutomationId = "test-invoke-view" }; + using var harness = await InvokeTestHarness.CreateAsync(view); + + var result = await harness.Client.InvokeElementMethodAsync( + "test-invoke-view", + nameof(TestInvokeView.GetValueAsync), + JsonArray(JsonElement("test"))); + + Assert.NotNull(result); + Assert.True(result.Success, result.Error); + Assert.Equal("async:test", result.ReturnValue); + } + + [Fact] + public async Task InvokeElement_ElementNotFound_ReturnsError() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeElementMethodAsync( + "nonexistent-element", + "SomeMethod"); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task InvokeElement_MethodNotFoundOnElement_ReturnsError() + { + var view = new TestInvokeView { AutomationId = "test-invoke-view" }; + using var harness = await InvokeTestHarness.CreateAsync(view); + + var result = await harness.Client.InvokeElementMethodAsync( + "test-invoke-view", + "NonExistentMethod"); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + // ── DI Service Resolution Tests ── + + [Fact] + public async Task Invoke_WithServiceResolve_InstanceMethodExists_NoHandler_ReturnsContainerError() + { + // TestService has public instance methods, so the method resolution succeeds, + // but DI resolution fails because TestApplication has no Handler/MauiContext. + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestService).FullName!, + nameof(TestService.GetGreeting), + JsonArray(JsonElement("World")), + resolve: "service"); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("Could not resolve type", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DI container", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Invoke_WithServiceResolve_StaticMethodNotVisible_ReturnsMethodNotFound() + { + // When resolve is "service", only instance methods are searched (BindingFlags.Instance). + // Static-only methods should yield "method not found". + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.Add), + JsonArray(JsonElement(1), JsonElement(2)), + resolve: "service"); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + // TODO: Full DI service resolution test (success path) requires mocking + // IElementHandler, IMauiContext, and IServiceProvider on TestApplication.Handler. + // This would need: app.Handler = mockHandler where mockHandler.MauiContext.Services + // returns an IServiceProvider that resolves the target type. Currently only the + // error path is tested since TestApplication has no handler infrastructure. + #region Helpers private static System.Text.Json.Nodes.JsonArray JsonArray(params JsonElement[] elements) @@ -287,6 +588,18 @@ private static JsonElement JsonElement(T value) return JsonDocument.Parse(json).RootElement.Clone(); } + /// + /// Mimics the MCP InvokeTools argsJson parsing: takes a raw JSON string + /// and parses it into a JsonArray, exactly as the MCP tools do. + /// + private static System.Text.Json.Nodes.JsonArray? ParseMcpArgsJson(string? argsJson) + { + if (string.IsNullOrWhiteSpace(argsJson)) + return null; + var node = System.Text.Json.Nodes.JsonNode.Parse(argsJson); + return node as System.Text.Json.Nodes.JsonArray; + } + #endregion #region Test Harness @@ -412,4 +725,24 @@ public enum Priority High } +/// +/// Test helper class with overloaded methods for overload resolution tests. +/// +public static class TestInvokeHelpersWithOverloads +{ + public static string Concat(string a, string b) + => $"{a} {b}"; + + public static string Concat(string a, string b, string c) + => $"{a}-{b}-{c}"; +} + +/// +/// Non-static test class with instance methods for DI service resolution tests. +/// +public class TestServiceClass +{ + public string GetValue() => "service-value"; +} + #endregion diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/Microsoft.Maui.DevFlow.Tests.csproj b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/Microsoft.Maui.DevFlow.Tests.csproj index d0a20304d..0ef1c3de6 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/Microsoft.Maui.DevFlow.Tests.csproj +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/Microsoft.Maui.DevFlow.Tests.csproj @@ -10,11 +10,13 @@ + + From 7450fd7501e81894b0779228d685ae571a3d4b4f Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:39:23 -0400 Subject: [PATCH 13/24] test: Add MCP-style integration tests for invoke pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 10 new tests that exercise the AgentClient invoke methods using the same parameter patterns that MCP InvokeTools pass: - JSON string parsing (argsJson → JsonArray) for InvokeAction and Invoke - Explicit resolve: "static" parameter - resolve: "service" error when no DI container is available - Overloaded method resolution by argument count - Mixed-type and nested array argument conversion Also adds TestInvokeHelpersWithOverloads (2-arg and 3-arg Concat) and TestServiceClass (instance methods) as test fixture types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index 323cc3d0a..7badeb096 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -616,8 +616,11 @@ private InvokeTestHarness(DevFlowAgentService service, AgentClient client) } public static async Task CreateAsync() + => await CreateAsync(Array.Empty()); + + public static async Task CreateAsync(params View[] views) { - var app = new TestApplication([]); + var app = new TestApplication(views); var service = new DevFlowAgentService(new AgentOptions { Port = GetFreePort() }); var client = new AgentClient("localhost", service.Port); From 539c80975ae6c93747edeeaecb313fa60e946559 Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 24 Apr 2026 18:40:13 -0400 Subject: [PATCH 14/24] test: Add element invoke and DI service resolution tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InvokeTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index 7badeb096..7d1fced64 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -748,4 +748,24 @@ public class TestServiceClass public string GetValue() => "service-value"; } +/// +/// Test View subclass with public instance methods for element invoke tests. +/// Added as a child of TestApplication so the tree walker can find it by AutomationId. +/// +public class TestInvokeView : View +{ + public string TestMethod(string input) => $"result:{input}"; + public int AddNumbers(int a, int b) => a + b; + public Task GetValueAsync(string key) => Task.FromResult($"async:{key}"); +} + +/// +/// Test service class with public instance methods for DI service resolution tests. +/// Used to verify the "resolve: service" error path when no DI container is available. +/// +public class TestService +{ + public string GetGreeting(string name) => $"Hello from service, {name}!"; +} + #endregion From 255f4d9b33c39ce6b154a5215ce7923c6f92f07e Mon Sep 17 00:00:00 2001 From: redth Date: Mon, 27 Apr 2026 09:27:43 -0400 Subject: [PATCH 15/24] =?UTF-8?q?fix:=20Address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20ConvertInvokeArg=20robustness,=20TPA-based=20framew?= =?UTF-8?q?ork=20filtering,=20type=20resolution=20ambiguity,=20overload=20?= =?UTF-8?q?selection,=20documentation=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConvertInvokeArg: check ValueKind before GetString() for bool, numeric, enum paths - Enum conversion: handle Number via Enum.ToObject, String via Enum.Parse - HandleInvokeAction/HandleInvoke: catch Exception instead of only ArgumentException - IsFrameworkAssembly: use TRUSTED_PLATFORM_ASSEMBLIES for comprehensive filtering - ResolveType: detect ambiguous simple-name matches across assemblies - Overload selection: explicit error when multiple overloads match arg count - ListMethods: detect ValueTask/ValueTask as async - MCP tool description: remove misleading element reference mention - Analyzer tests: remove redundant DescriptionAttribute stub - AnalyzerReleases: move all rules to Unshipped (no release yet) - SKILL.md: add DFA005 diagnostic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/devflow-automation/SKILL.md | 3 + .../DevFlow/Mcp/Tools/InvokeTools.cs | 4 +- .../DevFlowAgentService.Invoke.cs | 120 +++++++++++++----- .../AnalyzerReleases.Shipped.md | 12 +- .../AnalyzerReleases.Unshipped.md | 4 + .../DevFlowActionAnalyzerTests.cs | 9 -- 6 files changed, 102 insertions(+), 50 deletions(-) diff --git a/plugins/dotnet-maui/skills/devflow-automation/SKILL.md b/plugins/dotnet-maui/skills/devflow-automation/SKILL.md index 63e7c3ba4..8f1809ec2 100644 --- a/plugins/dotnet-maui/skills/devflow-automation/SKILL.md +++ b/plugins/dotnet-maui/skills/devflow-automation/SKILL.md @@ -176,6 +176,9 @@ The `Microsoft.Maui.DevFlow.Agent.Core` NuGet package includes a Roslyn analyzer | MAUI_DFA002 | Error | Method must be public static | | MAUI_DFA003 | Warning | Return type may not serialize cleanly | | MAUI_DFA004 | Info | Missing `[Description]` on parameter | +| MAUI_DFA005 | Warning | Duplicate `[DevFlowAction]` name | + +Action names must be unique across the project. Duplicates cause the later registration to be silently ignored at runtime. ## Capabilities Detection diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs index 3b5c43c91..8ad8de80b 100644 --- a/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs +++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs @@ -99,8 +99,8 @@ Arguments are a JSON array of values matching parameter order. Types are auto-co argsJson: '["user@test.com", "pass123"]' resolve: "service" - For methods on UI elements, use maui_invoke with an element reference, - or call maui_invoke_action for registered shortcuts. + This tool only invokes methods by type name or DI-resolved service type. + For registered shortcuts, use maui_invoke_action. """)] public static async Task Invoke( McpAgentSession session, diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index d8ad5dfa6..65d3fe197 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -107,16 +107,50 @@ private static InvokeActionEntry[] ScanActions() .ToArray(); } + private static readonly Lazy> s_trustedPlatformAssemblyNames = new( + GetTrustedPlatformAssemblyNames, + LazyThreadSafetyMode.ExecutionAndPublication); + private static bool IsFrameworkAssembly(Assembly asm) { var name = asm.GetName().Name; - if (name == null) return true; - return name.StartsWith("System", StringComparison.Ordinal) - || name.StartsWith("Microsoft.Extensions", StringComparison.Ordinal) - || name.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal) - || name.StartsWith("netstandard", StringComparison.Ordinal) - || name.StartsWith("mscorlib", StringComparison.Ordinal) - || name.StartsWith("Fizzler", StringComparison.Ordinal) + if (string.IsNullOrEmpty(name)) + return true; + + return s_trustedPlatformAssemblyNames.Value.Contains(name) + || IsExplicitlyBlockedAssembly(name); + } + + private static HashSet GetTrustedPlatformAssemblyNames() + { + var trustedPlatformAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + var assemblyNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrEmpty(trustedPlatformAssemblies)) + return assemblyNames; + + // Determine the shared framework directory so we only treat assemblies + // shipped with the runtime as "framework". TPA may also include app + // assemblies (e.g. in test runners), which we must NOT filter out. + var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location); + + foreach (var path in trustedPlatformAssemblies.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + // Only include assemblies that live in the shared framework directory + if (runtimeDir != null && !path.StartsWith(runtimeDir, StringComparison.OrdinalIgnoreCase)) + continue; + + var assemblyName = Path.GetFileNameWithoutExtension(path); + if (!string.IsNullOrEmpty(assemblyName)) + assemblyNames.Add(assemblyName); + } + + return assemblyNames; + } + + private static bool IsExplicitlyBlockedAssembly(string name) + { + return name.StartsWith("Fizzler", StringComparison.Ordinal) || name.StartsWith("SkiaSharp", StringComparison.Ordinal); } @@ -194,7 +228,7 @@ private static string FormatParameterTypeName(Type type) } // Scan loaded assemblies - Type? bestMatch = null; + var matches = new List(); foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { if (asm.IsDynamic || IsFrameworkAssembly(asm)) continue; @@ -215,17 +249,28 @@ private static string FormatParameterTypeName(Type type) foreach (var t in asm.GetTypes()) { if (string.Equals(t.Name, typeName, StringComparison.OrdinalIgnoreCase)) - bestMatch = t; + matches.Add(t); } } catch { } } } - if (bestMatch != null) - _typeResolutionCache.TryAdd(typeName, bestMatch); + // Deduplicate: if all matches refer to the same type, use it + var distinct = matches.Select(t => t.FullName).Distinct().ToList(); + if (distinct.Count == 1) + { + _typeResolutionCache.TryAdd(typeName, matches[0]); + return matches[0]; + } + + if (distinct.Count > 1) + { + System.Diagnostics.Debug.WriteLine( + $"[Microsoft.Maui.DevFlow] Warning: Ambiguous type name '{typeName}' matched {distinct.Count} types: {string.Join(", ", distinct)}. Use a fully-qualified type name to resolve the ambiguity."); + } - return bestMatch; + return null; } #endregion @@ -253,27 +298,35 @@ private static string FormatParameterTypeName(Type type) { if (argElement.ValueKind == JsonValueKind.True || argElement.ValueKind == JsonValueKind.False) return argElement.GetBoolean(); + if (argElement.ValueKind != JsonValueKind.String) + throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}"); var str = argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}"); return bool.Parse(str); } // Integer types - if (underlying == typeof(int)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt32() : int.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); - if (underlying == typeof(long)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt64() : long.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); - if (underlying == typeof(short)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt16() : short.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); - if (underlying == typeof(byte)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetByte() : byte.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); + if (underlying == typeof(int)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt32() : argElement.ValueKind == JsonValueKind.String ? int.Parse(argElement.GetString()!) : throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}"); + if (underlying == typeof(long)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt64() : argElement.ValueKind == JsonValueKind.String ? long.Parse(argElement.GetString()!) : throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}"); + if (underlying == typeof(short)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetInt16() : argElement.ValueKind == JsonValueKind.String ? short.Parse(argElement.GetString()!) : throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}"); + if (underlying == typeof(byte)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetByte() : argElement.ValueKind == JsonValueKind.String ? byte.Parse(argElement.GetString()!) : throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}"); // Floating point - if (underlying == typeof(float)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetSingle() : float.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); - if (underlying == typeof(double)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetDouble() : double.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); - if (underlying == typeof(decimal)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetDecimal() : decimal.Parse(argElement.GetString() ?? throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}")); + if (underlying == typeof(float)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetSingle() : argElement.ValueKind == JsonValueKind.String ? float.Parse(argElement.GetString()!) : throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}"); + if (underlying == typeof(double)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetDouble() : argElement.ValueKind == JsonValueKind.String ? double.Parse(argElement.GetString()!) : throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}"); + if (underlying == typeof(decimal)) return argElement.ValueKind == JsonValueKind.Number ? argElement.GetDecimal() : argElement.ValueKind == JsonValueKind.String ? decimal.Parse(argElement.GetString()!) : throw new ArgumentException($"Cannot convert {argElement.ValueKind} to {underlying.Name}"); // Enums if (underlying.IsEnum) { - var s = argElement.GetString() ?? argElement.GetRawText(); - return Enum.Parse(underlying, s, ignoreCase: true); + if (argElement.ValueKind == JsonValueKind.String) + { + var s = argElement.GetString() ?? throw new ArgumentException($"Cannot convert null string to {underlying.Name}"); + return Enum.Parse(underlying, s, ignoreCase: true); + } + if (argElement.ValueKind == JsonValueKind.Number) + return Enum.ToObject(underlying, argElement.GetInt64()); + throw new ArgumentException($"Cannot convert {argElement.ValueKind} to enum {underlying.Name}"); } // Arrays and lists @@ -459,7 +512,7 @@ private async Task HandleInvokeAction(HttpRequest request) ? HttpResponse.Json(new { success = true, action = action.Name, returnValue, returnType }) : InvokeError($"Action '{actionName}' failed: {error}"); } - catch (ArgumentException ex) + catch (Exception ex) { return InvokeError($"Argument error: {ex.Message}"); } @@ -499,21 +552,28 @@ private async Task HandleInvoke(HttpRequest request) else { var argCount = body.Args?.Length ?? 0; - var matched = candidates.FirstOrDefault(m => + var matched = candidates.Where(m => { var ps = m.GetParameters(); var required = ps.Count(p => !p.HasDefaultValue); return argCount >= required && argCount <= ps.Length; - }); + }).ToArray(); - if (matched == null) + if (matched.Length == 0) { var signatures = string.Join(", ", candidates.Select(m => $"{m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})")); - return InvokeError($"Ambiguous method '{body.MethodName}' on type '{type.FullName}'. Candidates: {signatures}"); + return InvokeError($"No overload of '{body.MethodName}' on type '{type.FullName}' matches {argCount} argument(s). Candidates: {signatures}"); + } + + if (matched.Length > 1) + { + var signatures = string.Join(", ", matched.Select(m => + $"{m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})")); + return InvokeError($"Ambiguous method '{body.MethodName}' on type '{type.FullName}' — {matched.Length} overloads match {argCount} argument(s). Use a fully-qualified type or adjust argument count. Candidates: {signatures}"); } - method = matched; + method = matched[0]; } object? target = null; @@ -549,7 +609,7 @@ private async Task HandleInvoke(HttpRequest request) ? HttpResponse.Json(new { success = true, typeName = type.FullName, methodName = method.Name, returnValue, returnType }) : InvokeError($"Invoke failed: {error}"); } - catch (ArgumentException ex) + catch (Exception ex) { return InvokeError($"Argument error: {ex.Message}"); } @@ -624,7 +684,9 @@ private Task HandleListMethods(HttpRequest request) name = m.Name, returnType = FormatParameterTypeName(m.ReturnType), isStatic = m.IsStatic, - isAsync = typeof(Task).IsAssignableFrom(m.ReturnType), + isAsync = typeof(Task).IsAssignableFrom(m.ReturnType) + || m.ReturnType == typeof(ValueTask) + || (m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)), devFlowActionName = actionAttr?.Name, parameters = m.GetParameters().Select(p => new { diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Shipped.md b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Shipped.md index 36b38dd26..f50bb1fe2 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Shipped.md @@ -1,10 +1,2 @@ -## Release 0.1.0 - -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|------- -MAUI_DFA001 | DevFlow | Error | Unsupported parameter type for [DevFlowAction] -MAUI_DFA002 | DevFlow | Error | [DevFlowAction] method must be public static -MAUI_DFA003 | DevFlow | Warning | Return type may not serialize cleanly -MAUI_DFA004 | DevFlow | Info | Parameter missing [Description] attribute +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md index 145ab445e..0c8529139 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Analyzers/AnalyzerReleases.Unshipped.md @@ -2,4 +2,8 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +MAUI_DFA001 | DevFlow | Error | Unsupported parameter type for [DevFlowAction] +MAUI_DFA002 | DevFlow | Error | [DevFlowAction] method must be public static +MAUI_DFA003 | DevFlow | Warning | Return type may not serialize cleanly +MAUI_DFA004 | DevFlow | Info | Parameter missing [Description] attribute MAUI_DFA005 | DevFlow | Warning | Duplicate [DevFlowAction] name diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/DevFlowActionAnalyzerTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/DevFlowActionAnalyzerTests.cs index 281595765..d7c329b1b 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/DevFlowActionAnalyzerTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/DevFlowActionAnalyzerTests.cs @@ -11,15 +11,6 @@ public class DevFlowActionAnalyzerTests // can resolve them without referencing the real Agent.Core assembly. private const string AttributeStubs = """ - namespace System.ComponentModel - { - [System.AttributeUsage(System.AttributeTargets.All)] - public sealed class DescriptionAttribute : System.Attribute - { - public DescriptionAttribute(string description) { } - } - } - namespace Microsoft.Maui.DevFlow.Agent.Core { [System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = false)] From f4b409e7aaf9b0f190dc3e415f586b8773a1fb53 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 28 Apr 2026 08:51:31 -0400 Subject: [PATCH 16/24] fix: Clear invoke type cache on invalidation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 16 ++++++++-------- .../InvokeTests.cs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 65d3fe197..5fcc055bd 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -29,20 +29,21 @@ internal static void UpdateApplication(Type[]? updatedTypes) public partial class DevFlowAgentService { private static volatile Lazy s_cachedActions = new(ScanActions, LazyThreadSafetyMode.ExecutionAndPublication); - private readonly ConcurrentDictionary _typeResolutionCache = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary s_typeResolutionCache = new(StringComparer.OrdinalIgnoreCase); #region Action Discovery private InvokeActionEntry[] DiscoverActions() => s_cachedActions.Value; /// - /// Invalidates the cached DevFlowAction list. The next call to - /// DiscoverActions() will rescan all loaded assemblies. + /// Invalidates cached reflection data. The next call to DiscoverActions() + /// will rescan all loaded assemblies, and type resolution will rescan as needed. /// Called by AssemblyLoad handler and MetadataUpdateHandler (Hot Reload). /// internal static void InvalidateActionCache() { s_cachedActions = new Lazy(ScanActions, LazyThreadSafetyMode.ExecutionAndPublication); + s_typeResolutionCache.Clear(); } private void OnAssemblyLoaded(object? sender, AssemblyLoadEventArgs args) @@ -51,7 +52,6 @@ private void OnAssemblyLoaded(object? sender, AssemblyLoadEventArgs args) return; InvalidateActionCache(); - _typeResolutionCache.Clear(); } private static InvokeActionEntry[] ScanActions() @@ -211,7 +211,7 @@ private static string FormatParameterTypeName(Type type) private Type? ResolveType(string typeName) { - if (_typeResolutionCache.TryGetValue(typeName, out var cached)) + if (s_typeResolutionCache.TryGetValue(typeName, out var cached)) return cached; // Try fully-qualified name first @@ -222,7 +222,7 @@ private static string FormatParameterTypeName(Type type) type = null; else { - _typeResolutionCache.TryAdd(typeName, type); + s_typeResolutionCache.TryAdd(typeName, type); return type; } } @@ -237,7 +237,7 @@ private static string FormatParameterTypeName(Type type) type = asm.GetType(typeName, throwOnError: false, ignoreCase: true); if (type != null) { - _typeResolutionCache.TryAdd(typeName, type); + s_typeResolutionCache.TryAdd(typeName, type); return type; } @@ -260,7 +260,7 @@ private static string FormatParameterTypeName(Type type) var distinct = matches.Select(t => t.FullName).Distinct().ToList(); if (distinct.Count == 1) { - _typeResolutionCache.TryAdd(typeName, matches[0]); + s_typeResolutionCache.TryAdd(typeName, matches[0]); return matches[0]; } diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index 7d1fced64..cbf7a99fc 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -1,6 +1,8 @@ +using System.Collections.Concurrent; using System.ComponentModel; using System.Net; using System.Net.Sockets; +using System.Reflection; using System.Text.Json; using Microsoft.Maui; using Microsoft.Maui.Controls; @@ -155,6 +157,23 @@ public async Task Invoke_TypeNotFound_ReturnsError() Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void InvalidateActionCache_ClearsTypeResolutionCache() + { + var cacheField = typeof(DevFlowAgentService).GetField("s_typeResolutionCache", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(cacheField); + var cache = Assert.IsType>(cacheField.GetValue(null)); + + const string cacheKey = "__hot_reload_cache_test__"; + cache[cacheKey] = typeof(TestInvokeHelpers); + + var invalidateMethod = typeof(DevFlowAgentService).GetMethod("InvalidateActionCache", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(invalidateMethod); + invalidateMethod.Invoke(null, null); + + Assert.False(cache.ContainsKey(cacheKey)); + } + [Fact] public async Task ListMethods_ReturnsPublicMethods_ForType() { From 441f37adbd3774610b75e5f0c9f9adb2c2852d9b Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 28 Apr 2026 08:54:18 -0400 Subject: [PATCH 17/24] fix: Return error status for invoke failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 2 +- .../AgentClient.cs | 4 +-- .../InvokeTests.cs | 31 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 5fcc055bd..0ede0dcc4 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -464,7 +464,7 @@ private static string FormatParameterTypeName(Type type) #region HTTP Handlers private static HttpResponse InvokeError(string error) => - HttpResponse.Json(new { success = false, error }); + HttpResponse.Error(error); private Task HandleListActions(HttpRequest request) { diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs index a90d77d77..2b50f579a 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs @@ -513,9 +513,9 @@ private async Task PostActionAsync(string path, JsonNode body) { using var content = DriverJson.CreateJsonContent(body); var response = await _http.PostAsync($"{_baseUrl}{path}", content); - if (!response.IsSuccessStatusCode) - return null; var responseBody = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(responseBody)) + return null; return DriverJson.Deserialize(responseBody); } catch diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index cbf7a99fc..5eb31e726 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -209,6 +209,37 @@ public async Task InvokeAction_NotFound_ReturnsError() Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Batch_WithInvokeFailure_StopsAndReportsFailure() + { + using var harness = await InvokeTestHarness.CreateAsync(); + TestInvokeHelpers.LastSideEffect = null; + + var result = await harness.Client.BatchAsync( + [ + new System.Text.Json.Nodes.JsonObject + { + ["action"] = "invoke", + ["typeName"] = typeof(TestInvokeHelpers).FullName, + ["methodName"] = "NonExistentMethod" + }, + new System.Text.Json.Nodes.JsonObject + { + ["action"] = "invoke", + ["typeName"] = typeof(TestInvokeHelpers).FullName, + ["methodName"] = nameof(TestInvokeHelpers.DoSideEffect), + ["args"] = JsonArray(JsonElement("should-not-run")) + } + ], + continueOnError: false); + + Assert.False(result.GetProperty("success").GetBoolean()); + var onlyResult = Assert.Single(result.GetProperty("results").EnumerateArray()); + Assert.False(onlyResult.GetProperty("success").GetBoolean()); + Assert.Equal(400, onlyResult.GetProperty("statusCode").GetInt32()); + Assert.Null(TestInvokeHelpers.LastSideEffect); + } + [Fact] public async Task Invoke_WithArrayParameter_ConvertsJsonArray() { From c2ab5e1f1a2f021e477eeb7c54282ad565d0b513 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 28 Apr 2026 08:59:55 -0400 Subject: [PATCH 18/24] Fix invoke handlers to dispatch reflected calls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 6 +- .../InvokeTests.cs | 77 ++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 0ede0dcc4..401bf96f1 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -506,7 +506,8 @@ private async Task HandleInvokeAction(HttpRequest request) try { var convertedArgs = ConvertInvokeArgs(action.Method.GetParameters(), args); - var (success, returnValue, returnType, error) = await InvokeMethodAsync(action.Method, null, convertedArgs); + var invokeTask = await DispatchAsync(() => InvokeMethodAsync(action.Method, null, convertedArgs)); + var (success, returnValue, returnType, error) = await invokeTask; return success ? HttpResponse.Json(new { success = true, action = action.Name, returnValue, returnType }) @@ -602,7 +603,8 @@ private async Task HandleInvoke(HttpRequest request) } else { - (success, returnValue, returnType, error) = await InvokeMethodAsync(method, target, convertedArgs); + var invokeTask = await DispatchAsync(() => InvokeMethodAsync(method, target, convertedArgs)); + (success, returnValue, returnType, error) = await invokeTask; } return success diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index 5eb31e726..d0313d0b0 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -315,6 +315,37 @@ public async Task Invoke_WithNullableParameter_PassesNull() Assert.Equal("null", result.ReturnValue); } + [Fact] + public async Task InvokeAction_DispatchesInvocationToUiThread() + { + using var harness = await InvokeTestHarness.CreateWithDispatcherAsync(new DispatchRequiredDispatcher()); + + TestInvokeHelpers.ResetDispatchState(); + var result = await harness.Client.InvokeActionAsync("test-dispatch-state"); + + Assert.NotNull(result); + Assert.True(result.Success, result.Error); + Assert.Equal("dispatched", result.ReturnValue); + Assert.True(TestInvokeHelpers.DispatchCallCount > 0); + } + + [Fact] + public async Task Invoke_WithStaticResolve_DispatchesInvocationToUiThread() + { + using var harness = await InvokeTestHarness.CreateWithDispatcherAsync(new DispatchRequiredDispatcher()); + + TestInvokeHelpers.ResetDispatchState(); + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpers).FullName!, + nameof(TestInvokeHelpers.GetDispatchState), + resolve: "static"); + + Assert.NotNull(result); + Assert.True(result.Success, result.Error); + Assert.Equal("dispatched", result.ReturnValue); + Assert.True(TestInvokeHelpers.DispatchCallCount > 0); + } + // ── MCP-style integration tests ── // These tests exercise the AgentClient methods using the same parameter patterns // that the MCP InvokeTools pass (JSON string → JsonArray parsing, explicit resolve, etc.) @@ -669,12 +700,15 @@ public static async Task CreateAsync() => await CreateAsync(Array.Empty()); public static async Task CreateAsync(params View[] views) + => await CreateWithDispatcherAsync(new ImmediateDispatcher(), views); + + public static async Task CreateWithDispatcherAsync(IDispatcher dispatcher, params View[] views) { var app = new TestApplication(views); var service = new DevFlowAgentService(new AgentOptions { Port = GetFreePort() }); var client = new AgentClient("localhost", service.Port); - service.StartServerOnly(new ImmediateDispatcher()); + service.StartServerOnly(dispatcher); service.BindApp(app); for (var i = 0; i < 10; i++) @@ -710,6 +744,30 @@ private sealed class ImmediateDispatcher : IDispatcher public IDispatcherTimer CreateTimer() => new ImmediateDispatcherTimer(); } + private sealed class DispatchRequiredDispatcher : IDispatcher + { + public bool IsDispatchRequired => true; + + public bool Dispatch(Action action) + { + TestInvokeHelpers.DispatchCallCount++; + var wasDispatched = TestInvokeHelpers.IsDispatched; + TestInvokeHelpers.IsDispatched = true; + try + { + action(); + } + finally + { + TestInvokeHelpers.IsDispatched = wasDispatched; + } + return true; + } + + public bool DispatchDelayed(TimeSpan delay, Action action) => Dispatch(action); + public IDispatcherTimer CreateTimer() => new ImmediateDispatcherTimer(); + } + private sealed class ImmediateDispatcherTimer : IDispatcherTimer { public bool IsRepeating { get; set; } @@ -739,8 +797,18 @@ private sealed class TestApplication : Application, IVisualTreeElement /// public static class TestInvokeHelpers { + [ThreadStatic] + public static bool IsDispatched; + + public static int DispatchCallCount { get; set; } public static string? LastSideEffect { get; set; } + public static void ResetDispatchState() + { + IsDispatched = false; + DispatchCallCount = 0; + } + [DevFlowAction("test-greet", Description = "Returns a greeting for the given name")] public static string Greet( [Description("The name to greet")] string name = "Friend") @@ -752,6 +820,13 @@ public static int Add( [Description("Second number")] int b) => a + b; + [DevFlowAction("test-dispatch-state", Description = "Returns whether invocation is dispatched")] + public static string GetActionDispatchState() + => GetDispatchState(); + + public static string GetDispatchState() + => IsDispatched ? "dispatched" : "not-dispatched"; + public static Task GetValueAsync(string key) => Task.FromResult($"async:{key}"); From 2d3ac48f393025030ae1c9f448596dfcc9b1aa64 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 28 Apr 2026 09:01:51 -0400 Subject: [PATCH 19/24] fix: Block MAUI framework invoke assemblies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 24 ++++++++++++++++++- .../InvokeTests.cs | 17 +++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 401bf96f1..522624cad 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -151,7 +151,29 @@ private static HashSet GetTrustedPlatformAssemblyNames() private static bool IsExplicitlyBlockedAssembly(string name) { return name.StartsWith("Fizzler", StringComparison.Ordinal) - || name.StartsWith("SkiaSharp", StringComparison.Ordinal); + || name.StartsWith("SkiaSharp", StringComparison.Ordinal) + || IsMicrosoftMauiFrameworkAssembly(name) + || IsDevFlowAssembly(name) + || name.StartsWith("Microsoft.CSharp", StringComparison.Ordinal) + || name.StartsWith("Microsoft.Win32", StringComparison.Ordinal); + } + + private static bool IsMicrosoftMauiFrameworkAssembly(string name) + { + return string.Equals(name, "Microsoft.Maui", StringComparison.Ordinal) + || (name.StartsWith("Microsoft.Maui.", StringComparison.Ordinal) + && !name.StartsWith("Microsoft.Maui.DevFlow.", StringComparison.Ordinal)); + } + + private static bool IsDevFlowAssembly(string name) + { + return string.Equals(name, "Microsoft.Maui.DevFlow.Agent", StringComparison.Ordinal) + || string.Equals(name, "Microsoft.Maui.DevFlow.Agent.Core", StringComparison.Ordinal) + || string.Equals(name, "Microsoft.Maui.DevFlow.Agent.Gtk", StringComparison.Ordinal) + || string.Equals(name, "Microsoft.Maui.DevFlow.Blazor", StringComparison.Ordinal) + || string.Equals(name, "Microsoft.Maui.DevFlow.Blazor.Gtk", StringComparison.Ordinal) + || string.Equals(name, "Microsoft.Maui.DevFlow.Driver", StringComparison.Ordinal) + || string.Equals(name, "Microsoft.Maui.DevFlow.Logging", StringComparison.Ordinal); } private static InvokeParameterInfo[] BuildParameterInfoList(MethodInfo method) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index d0313d0b0..0dcfcb5cb 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -174,6 +174,23 @@ public void InvalidateActionCache_ClearsTypeResolutionCache() Assert.False(cache.ContainsKey(cacheKey)); } + [Theory] + [InlineData("Microsoft.Maui", true)] + [InlineData("Microsoft.Maui.Controls", true)] + [InlineData("Microsoft.Maui.DevFlow.Agent.Core", true)] + [InlineData("Microsoft.CSharp", true)] + [InlineData("Microsoft.Win32.Registry", true)] + [InlineData("Microsoft.Maui.DevFlow.Tests", false)] + public void IsExplicitlyBlockedAssembly_FiltersFrameworkAndDevFlowAssemblies(string assemblyName, bool expected) + { + var isBlockedMethod = typeof(DevFlowAgentService).GetMethod("IsExplicitlyBlockedAssembly", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(isBlockedMethod); + + var actual = Assert.IsType(isBlockedMethod.Invoke(null, new object[] { assemblyName })); + + Assert.Equal(expected, actual); + } + [Fact] public async Task ListMethods_ReturnsPublicMethods_ForType() { From caa79ec7927d5a74eeac0a0b82f0f90dd33bf855 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 28 Apr 2026 09:02:41 -0400 Subject: [PATCH 20/24] fix: Dispatch service invoke async work Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 522624cad..7bdd5d2f1 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -481,6 +481,17 @@ private static string FormatParameterTypeName(Type type) } } + private async Task DispatchInvokeMethodAsync(MethodInfo method, object? target, object?[] args) + { + // Force the async DispatchAsync overload so invoke continuations are awaited by the dispatcher callback. + var result = await DispatchAsync(async () => + { + var (success, returnValue, returnType, error) = await InvokeMethodAsync(method, target, args); + return new InvokeMethodResult(success, returnValue, returnType, error); + }); + return result!; + } + #endregion #region HTTP Handlers @@ -620,8 +631,7 @@ private async Task HandleInvoke(HttpRequest request) if (isService) { // Service invoke must run on UI thread since the service may access UI state - var invokeTask = await DispatchAsync(() => InvokeMethodAsync(method, target, convertedArgs)); - (success, returnValue, returnType, error) = await invokeTask; + (success, returnValue, returnType, error) = await DispatchInvokeMethodAsync(method, target, convertedArgs); } else { @@ -748,6 +758,8 @@ private class InvokeParameterInfo public bool IsRequired { get; set; } } + private sealed record InvokeMethodResult(bool Success, string? ReturnValue, string? ReturnType, string? Error); + #endregion } From dd1c5f7d325dd68a746a9e3dfc195e9b7ba6f557 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 28 Apr 2026 09:08:52 -0400 Subject: [PATCH 21/24] fix: Restrict element invoke to app methods Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 15 +++++++++++++++ .../Microsoft.Maui.DevFlow.Tests/InvokeTests.cs | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 7bdd5d2f1..97f7dfa0e 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -492,6 +492,19 @@ private async Task DispatchInvokeMethodAsync(MethodInfo meth return result!; } + private static bool IsElementInvokeAllowedMethod(MethodInfo method) + { + var declaringType = method.DeclaringType; + if (declaringType == null || IsFrameworkAssembly(declaringType.Assembly)) + return false; + + var assemblyName = declaringType.Assembly.GetName().Name; + return !string.Equals(assemblyName, "Microsoft.Maui", StringComparison.Ordinal) + && (assemblyName == null + || !assemblyName.StartsWith("Microsoft.Maui.", StringComparison.Ordinal) + || assemblyName.StartsWith("Microsoft.Maui.DevFlow.", StringComparison.Ordinal)); + } + #endregion #region HTTP Handlers @@ -670,6 +683,8 @@ private async Task HandleElementInvoke(HttpRequest request) var method = type.GetMethod(body.MethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); if (method == null) return (element: (object?)null, method: (MethodInfo?)null, error: (string?)$"Method '{body.MethodName}' not found on element type '{type.Name}'"); + if (!IsElementInvokeAllowedMethod(method)) + return (element: (object?)null, method: (MethodInfo?)null, error: (string?)$"Method '{body.MethodName}' on element type '{type.Name}' is not invocable because it is declared by framework type '{method.DeclaringType?.FullName}'."); return (element: (object?)el, method: (MethodInfo?)method, error: (string?)null); }); diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index 0dcfcb5cb..ac9ec42db 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -619,6 +619,21 @@ public async Task InvokeElement_MethodNotFoundOnElement_ReturnsError() Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task InvokeElement_FrameworkDeclaredMethod_ReturnsError() + { + var view = new TestInvokeView { AutomationId = "test-invoke-view" }; + using var harness = await InvokeTestHarness.CreateAsync(view); + + var result = await harness.Client.InvokeElementMethodAsync( + "test-invoke-view", + nameof(GetType)); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("not invocable", result.Error, StringComparison.OrdinalIgnoreCase); + } + // ── DI Service Resolution Tests ── [Fact] From b3e0648f2cd76085544e1d6ea80b145ee6ab6462 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 28 Apr 2026 09:11:15 -0400 Subject: [PATCH 22/24] fix: Score invoke overload candidates Select invoke overloads using conservative JSON argument convertibility so obvious string and numeric overloads resolve without depending on metadata order, while equally compatible overloads still return an ambiguity error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.Invoke.cs | 164 +++++++++++++++--- .../InvokeTests.cs | 62 +++++++ 2 files changed, 200 insertions(+), 26 deletions(-) diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 97f7dfa0e..bda77ccf5 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -415,6 +415,127 @@ private static string FormatParameterTypeName(Type type) return result; } + private static int? ScoreInvokeCandidate(MethodInfo method, JsonElement[]? args) + { + var parameters = method.GetParameters(); + var argCount = args?.Length ?? 0; + var required = parameters.Count(p => !p.HasDefaultValue); + if (argCount < required || argCount > parameters.Length) + return null; + + var score = argCount == parameters.Length ? 1 : 0; + for (var i = 0; i < argCount; i++) + { + var argScore = ScoreInvokeArg(parameters[i].ParameterType, args![i]); + if (argScore == null) + return null; + + score += argScore.Value; + } + + return score; + } + + private static int? ScoreInvokeArg(Type targetType, JsonElement argElement) + { + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (argElement.ValueKind == JsonValueKind.Null) + return Nullable.GetUnderlyingType(targetType) != null || !targetType.IsValueType ? 6 : null; + + if (underlying == typeof(string)) + return argElement.ValueKind == JsonValueKind.String ? 6 : null; + + if (underlying == typeof(bool)) + return argElement.ValueKind switch + { + JsonValueKind.True or JsonValueKind.False => 6, + JsonValueKind.String => bool.TryParse(argElement.GetString(), out _) ? 2 : null, + _ => null + }; + + var numericScore = ScoreNumericInvokeArg(underlying, argElement); + if (numericScore != null) + return numericScore; + + if (underlying.IsEnum) + { + if (argElement.ValueKind == JsonValueKind.String) + return Enum.TryParse(underlying, argElement.GetString(), ignoreCase: true, out _) ? 4 : null; + if (argElement.ValueKind == JsonValueKind.Number) + return argElement.TryGetInt64(out _) ? 2 : null; + return null; + } + + if (argElement.ValueKind == JsonValueKind.Array && TryGetInvokeCollectionElementType(underlying, out var elementType)) + { + var score = 3; + foreach (var item in argElement.EnumerateArray()) + { + var itemScore = ScoreInvokeArg(elementType, item); + if (itemScore == null) + return null; + score += Math.Min(itemScore.Value, 4); + } + return score; + } + + return underlying == typeof(object) && argElement.ValueKind == JsonValueKind.String ? 1 : null; + } + + private static int? ScoreNumericInvokeArg(Type underlying, JsonElement argElement) + { + if (argElement.ValueKind == JsonValueKind.Number) + { + if (underlying == typeof(int)) return argElement.TryGetInt32(out _) ? 6 : null; + if (underlying == typeof(long)) return argElement.TryGetInt64(out _) ? 6 : null; + if (underlying == typeof(short)) return argElement.TryGetInt16(out _) ? 6 : null; + if (underlying == typeof(byte)) return argElement.TryGetByte(out _) ? 6 : null; + if (underlying == typeof(float)) return argElement.TryGetSingle(out _) ? 6 : null; + if (underlying == typeof(double)) return argElement.TryGetDouble(out _) ? 6 : null; + if (underlying == typeof(decimal)) return argElement.TryGetDecimal(out _) ? 6 : null; + } + + if (argElement.ValueKind != JsonValueKind.String) + return null; + + var value = argElement.GetString(); + if (underlying == typeof(int)) return int.TryParse(value, out _) ? 2 : null; + if (underlying == typeof(long)) return long.TryParse(value, out _) ? 2 : null; + if (underlying == typeof(short)) return short.TryParse(value, out _) ? 2 : null; + if (underlying == typeof(byte)) return byte.TryParse(value, out _) ? 2 : null; + if (underlying == typeof(float)) return float.TryParse(value, out _) ? 2 : null; + if (underlying == typeof(double)) return double.TryParse(value, out _) ? 2 : null; + if (underlying == typeof(decimal)) return decimal.TryParse(value, out _) ? 2 : null; + + return null; + } + + private static bool TryGetInvokeCollectionElementType(Type type, out Type elementType) + { + if (type.IsArray) + { + elementType = type.GetElementType()!; + return true; + } + + if (type.IsGenericType) + { + var def = type.GetGenericTypeDefinition(); + if (def == typeof(List<>) || def == typeof(IList<>) || def == typeof(IEnumerable<>) || def == typeof(IReadOnlyList<>) || def == typeof(ICollection<>) || def == typeof(IReadOnlyCollection<>)) + { + elementType = type.GetGenericArguments()[0]; + return true; + } + } + + elementType = typeof(object); + return false; + } + + private static string FormatInvokeMethodSignature(MethodInfo method) => + $"{method.Name}({string.Join(", ", method.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})"; + #endregion #region Invoke Execution @@ -591,38 +712,29 @@ private async Task HandleInvoke(HttpRequest request) if (candidates.Length == 0) return InvokeError($"Method '{body.MethodName}' not found on type '{type.FullName}'."); - MethodInfo method; - if (candidates.Length == 1) + var argCount = body.Args?.Length ?? 0; + var scored = candidates + .Select(m => new { Method = m, Score = ScoreInvokeCandidate(m, body.Args) }) + .Where(m => m.Score != null) + .ToArray(); + + if (scored.Length == 0) { - method = candidates[0]; + var signatures = string.Join(", ", candidates.Select(FormatInvokeMethodSignature)); + return InvokeError($"No overload of '{body.MethodName}' on type '{type.FullName}' matches {argCount} argument(s). Candidates: {signatures}"); } - else - { - var argCount = body.Args?.Length ?? 0; - var matched = candidates.Where(m => - { - var ps = m.GetParameters(); - var required = ps.Count(p => !p.HasDefaultValue); - return argCount >= required && argCount <= ps.Length; - }).ToArray(); - - if (matched.Length == 0) - { - var signatures = string.Join(", ", candidates.Select(m => - $"{m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})")); - return InvokeError($"No overload of '{body.MethodName}' on type '{type.FullName}' matches {argCount} argument(s). Candidates: {signatures}"); - } - if (matched.Length > 1) - { - var signatures = string.Join(", ", matched.Select(m => - $"{m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})")); - return InvokeError($"Ambiguous method '{body.MethodName}' on type '{type.FullName}' — {matched.Length} overloads match {argCount} argument(s). Use a fully-qualified type or adjust argument count. Candidates: {signatures}"); - } + var bestScore = scored.Max(m => m.Score!.Value); + var matched = scored.Where(m => m.Score == bestScore).Select(m => m.Method).ToArray(); - method = matched[0]; + if (matched.Length > 1) + { + var signatures = string.Join(", ", matched.Select(FormatInvokeMethodSignature)); + return InvokeError($"Ambiguous method '{body.MethodName}' on type '{type.FullName}' - {matched.Length} overloads match {argCount} argument(s). Use a fully-qualified type or adjust arguments. Candidates: {signatures}"); } + var method = matched[0]; + object? target = null; if (isService) { diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index ac9ec42db..3a4ab05e6 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -505,6 +505,56 @@ public async Task Invoke_OverloadedMethod_ResolvesBy3ArgCount() Assert.Equal("a-b-c", result.ReturnValue); } + [Fact] + public async Task Invoke_OverloadedMethod_ResolvesByStringArgumentType() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpersWithOverloads).FullName!, + "Choose", + JsonArray(JsonElement("42")), + resolve: "static"); + + Assert.NotNull(result); + Assert.True(result.Success, result.Error); + Assert.Equal("string:42", result.ReturnValue); + } + + [Fact] + public async Task Invoke_OverloadedMethod_ResolvesByNumberArgumentType() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpersWithOverloads).FullName!, + "Choose", + JsonArray(JsonElement(42)), + resolve: "static"); + + Assert.NotNull(result); + Assert.True(result.Success, result.Error); + Assert.Equal("int:42", result.ReturnValue); + } + + [Fact] + public async Task Invoke_OverloadedMethod_WithEquallyConvertibleArguments_ReturnsAmbiguous() + { + using var harness = await InvokeTestHarness.CreateAsync(); + + var result = await harness.Client.InvokeAsync( + typeof(TestInvokeHelpersWithOverloads).FullName!, + "AmbiguousNumber", + JsonArray(JsonElement(42)), + resolve: "static"); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("Ambiguous method", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Int32 value", result.Error, StringComparison.Ordinal); + Assert.Contains("Int64 value", result.Error, StringComparison.Ordinal); + } + [Fact] public async Task Invoke_WithMcpStyleComplexArgs_ConvertsTypes() { @@ -895,6 +945,18 @@ public static string Concat(string a, string b) public static string Concat(string a, string b, string c) => $"{a}-{b}-{c}"; + + public static string Choose(string value) + => $"string:{value}"; + + public static string Choose(int value) + => $"int:{value}"; + + public static string AmbiguousNumber(int value) + => $"int:{value}"; + + public static string AmbiguousNumber(long value) + => $"long:{value}"; } /// From 06139d3422691ed5122c5d7328183510416f5553 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 28 Apr 2026 09:59:38 -0400 Subject: [PATCH 23/24] Narrow DevFlow invoke to explicit actions Remove open reflection invoke, method discovery, element invoke, and generic invoke batch support so DevFlow automation only calls methods explicitly annotated with DevFlowAction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/devflow-automation/SKILL.md | 206 ++--- .../DevFlow/Mcp/Tools/InvokeTools.cs | 78 -- .../DevFlowAgentService.Invoke.cs | 476 +---------- .../DevFlowAgentService.cs | 22 +- .../AgentClient.cs | 47 +- .../InvokeTests.cs | 772 ++++-------------- 6 files changed, 222 insertions(+), 1379 deletions(-) diff --git a/plugins/dotnet-maui/skills/devflow-automation/SKILL.md b/plugins/dotnet-maui/skills/devflow-automation/SKILL.md index 8f1809ec2..780bced62 100644 --- a/plugins/dotnet-maui/skills/devflow-automation/SKILL.md +++ b/plugins/dotnet-maui/skills/devflow-automation/SKILL.md @@ -1,203 +1,135 @@ --- name: devflow-automation description: >- - Automate .NET MAUI app state via DevFlow reflection invoke and registered - actions. USE FOR: calling app methods via reflection, discovering and invoking - [DevFlowAction] shortcuts, logging in test users, seeding data, navigating to - deep screens, bypassing UI flows to reach target state quickly, calling DI - service methods. DO NOT USE FOR: basic UI interaction (tap/fill/scroll — use - DevFlow MCP tools directly), visual tree inspection, screenshot capture, - connectivity issues (use devflow-connect), or build/deployment problems. + Automate .NET MAUI app state via explicitly registered DevFlow Actions. USE + FOR: discovering and invoking [DevFlowAction] shortcuts, logging in test + users, seeding data, navigating to deep screens, bypassing long UI flows to + reach target state quickly. DO NOT USE FOR: calling arbitrary methods, + invoking DI services or framework types, basic UI interaction (tap/fill/scroll + - use DevFlow MCP tools directly), visual tree inspection, screenshot capture, + connectivity issues, or build/deployment problems. --- -# DevFlow Automation — Reflection Invoke +# DevFlow Automation - Actions -Invoke methods in a running .NET MAUI app via reflection to rapidly set up app state. This is your most powerful tool for reducing round-trip steps when debugging or testing. +DevFlow Actions are named shortcuts that a .NET MAUI app explicitly exposes for automation with `[DevFlowAction]`. Use them to reach useful app states quickly, such as logging in a test user, seeding data, toggling a feature flag, or navigating to a deep screen. -## Why This Matters +Actions are opt-in. DevFlow does not expose arbitrary reflection invoke; if you need a new shortcut, add an attributed method in app debug/test code, let Hot Reload apply it, then list and invoke the action. -Traditional DevFlow interaction (navigate → fill → tap → screenshot → repeat) works but is slow for multi-step flows like authentication, data setup, or deep navigation. If the app has helper methods — especially in debug builds — you can call them directly and skip the UI entirely. +## Start by Listing Actions -**Always check for available actions first.** A single `maui_list_actions` call can reveal shortcuts that save dozens of UI interaction steps. +Always check for available actions early in a DevFlow session: -## Two-Tier System - -### Tier 1: Registered DevFlow Actions (Preferred) - -App developers annotate methods with `[DevFlowAction]` to expose named, documented shortcuts: - -```csharp -[DevFlowAction("login-test-user", Description = "Log in as the standard test account")] -public static async Task LoginTestUser( - [Description("Email address")] string email = "test@example.com", - [Description("Password")] string password = "password123") -{ - await AuthService.LoginAsync(email, password); -} -``` - -**Discover actions:** ``` maui_list_actions ``` -**Invoke an action:** -``` -maui_invoke_action actionName="login-test-user" -maui_invoke_action actionName="login-test-user" argsJson='["alice@test.com", "secret"]' -maui_invoke_action actionName="seed-catalog" argsJson='[100]' -``` - -### Tier 2: Open Reflection Invoke (Flexible) +Look for action names and descriptions that match your goal. Common patterns: -When no registered action exists, call any public method by type and method name: +- `login-*` for authentication shortcuts +- `seed-*` for data population +- `navigate-*` for deep links or screen setup +- `set-*` for feature flags or configuration +- `reset-*` for state cleanup -**Static methods:** -``` -maui_invoke typeName="MyApp.DebugHelpers" methodName="ResetDatabase" -maui_invoke typeName="MyApp.DebugHelpers" methodName="LoginTestUser" argsJson='["user@test.com", "pass"]' -``` +## Invoke an Action -**DI service methods:** -``` -maui_invoke typeName="MyApp.Services.IAuthService" methodName="LoginAsync" argsJson='["user@test.com", "pass"]' resolve="service" -``` +Arguments are passed as a JSON array in parameter order. Omit trailing optional parameters to use their defaults. -**Discover methods on a type:** ``` -maui_list_methods typeName="MyApp.DebugHelpers" +maui_invoke_action actionName="login-test-user" +maui_invoke_action actionName="login-test-user" argsJson='["alice@test.com", "secret"]' +maui_invoke_action actionName="seed-catalog" argsJson='[100]' ``` -## When to Use Each Approach - -| Scenario | Approach | Why | -|----------|----------|-----| -| Starting a session — check what's available | `maui_list_actions` | Discover shortcuts before doing anything manual | -| App has a known debug helper | `maui_invoke_action` | Named, documented, safe to call | -| You know the source code has a useful method | `maui_invoke` with type+method | Direct reflection, no registration needed | -| You need to call a registered DI service | `maui_invoke` with `resolve="service"` | Resolves from the app's DI container | -| Need to explore what's callable | `maui_list_methods` | See all public methods on a type | -| Simple UI interaction (tap, type, scroll) | Use `maui_tap`, `maui_fill`, etc. | Standard DevFlow tools, no reflection needed | - -## Workflow: Efficient App State Setup - -### Step 1: Check for Registered Actions +After invoking an action, verify the state with a screenshot, tree query, or other DevFlow tools: ``` -maui_list_actions +maui_screenshot ``` -Look for actions that match your goal. Common patterns: -- `login-*` — authentication shortcuts -- `seed-*` — data population -- `navigate-*` — deep navigation -- `set-*` — feature flags, configuration -- `reset-*` — state cleanup +## Hot Reload Workflow -### Step 2: Use Actions or Fall Back to Invoke +If no useful action exists and you can edit the app: -If an action exists, invoke it. If not, check the app source for helper methods and use `maui_invoke`. +1. Add a public static method annotated with `[DevFlowAction]`. +2. Add `[Description]` to each parameter so agents know what to pass. +3. Save and let C# Hot Reload apply the change. +4. Call `maui_list_actions` again. +5. Invoke the new action with `maui_invoke_action`. -### Step 3: Verify with Screenshot +Example: -After invoking, take a screenshot to confirm the app reached the expected state: +```csharp +using System.ComponentModel; +using Microsoft.Maui.DevFlow.Agent.Core; -``` -maui_screenshot +public static class DebugHelpers +{ + [DevFlowAction("login-test-user", Description = "Log in as the standard test account")] + public static async Task LoginTestUser( + [Description("Email address for the test account")] string email = "test@example.com", + [Description("Password for the test account")] string password = "password123") + { + await AuthService.LoginAsync(email, password); + } +} ``` -### Step 4: Continue with Standard Tools - -Once the app is in the right state, use standard DevFlow tools (tree, tap, fill, etc.) for fine-grained interaction. - ## Supported Parameter Types -Arguments are passed as a JSON array. These types are auto-converted: +Arguments are converted from JSON to these action parameter types: -| Type | JSON Example | -|------|-------------| +| Type | JSON example | +|------|--------------| | `string` | `"hello"` | | `bool` | `true` or `false` | | `int`, `long`, `short`, `byte` | `42` | | `float`, `double`, `decimal` | `3.14` | | `enum` | `"MemberName"` (case-insensitive) | -| `string[]`, `int[]`, etc. | `["a", "b", "c"]` or `[1, 2, 3]` | -| `List` | Same as arrays | -| Nullable types | `null` or the value | +| arrays and supported list interfaces | `["a", "b"]` or `[1, 2, 3]` | +| nullable types | `null` or the value | ## Batch Support -Invoke actions as part of a batch for complex setup sequences: +Use `invoke-action` in batches when setup needs several steps: ```json { "actions": [ - {"action": "invoke-action", "name": "login-test-user"}, - {"action": "invoke", "typeName": "MyApp.Debug", "methodName": "NavigateTo", "args": ["settings"]}, - {"action": "tap", "elementId": "btn-advanced"} + { "action": "invoke-action", "name": "login-test-user" }, + { "action": "invoke-action", "name": "seed-catalog", "args": [100] }, + { "action": "tap", "elementId": "btn-advanced" } ] } ``` -## For App Developers: Adding DevFlow Actions - -### 1. Add the Attribute - -```csharp -using System.ComponentModel; -using Microsoft.Maui.DevFlow.Agent.Core; - -public static class DebugHelpers -{ - [DevFlowAction("login-test-user", Description = "Log in as the standard test account")] - public static async Task LoginTestUser( - [Description("Email address for the test account")] string email = "test@example.com", - [Description("Password for the test account")] string password = "password123") - { - await AuthService.LoginAsync(email, password); - } -} -``` - -### 2. Rules +## Rules for App Developers -- Methods **must be `public static`** (enforced by analyzer: MAUI_DFA002) -- Parameter types must be supported primitives, enums, or arrays/lists of these (MAUI_DFA001) -- Add `[Description]` to parameters so AI agents know what to pass (MAUI_DFA004) -- Return `void`, `Task`, or `Task` with a simple type (MAUI_DFA003 warns on complex returns) +- Methods must be `public static`. +- Parameters should be simple supported types, enums, nullable supported types, arrays, or supported list interfaces. +- Add `[Description]` to parameters so AI agents know what to pass. +- Prefer returning `void`, `Task`, `ValueTask`, `Task`, or `ValueTask` with simple return values. +- Action names should be unique and intention-revealing. -### 3. Roslyn Analyzer - -The `Microsoft.Maui.DevFlow.Agent.Core` NuGet package includes a Roslyn analyzer that validates `[DevFlowAction]` methods at compile time: +The DevFlow analyzer validates attributed methods: | Diagnostic | Severity | Description | -|-----------|----------|-------------| +|------------|----------|-------------| | MAUI_DFA001 | Error | Unsupported parameter type | | MAUI_DFA002 | Error | Method must be public static | | MAUI_DFA003 | Warning | Return type may not serialize cleanly | | MAUI_DFA004 | Info | Missing `[Description]` on parameter | | MAUI_DFA005 | Warning | Duplicate `[DevFlowAction]` name | -Action names must be unique across the project. Duplicates cause the later registration to be silently ignored at runtime. - -## Capabilities Detection - -Check if the connected agent supports invoke: - -``` -maui_status -``` - -The capabilities response includes an `invoke` section when supported. This handles version mismatches gracefully — if the app uses an older DevFlow agent without invoke support, the tools will report this clearly. - ## Common Patterns ### Authentication Bypass ``` -maui_list_actions # Check for login actions -maui_invoke_action actionName="login-test-user" # Use the shortcut -maui_screenshot # Verify logged-in state +maui_list_actions +maui_invoke_action actionName="login-test-user" +maui_screenshot ``` ### Data Seeding @@ -210,11 +142,11 @@ maui_invoke_action actionName="seed-orders" argsJson='[50, true]' ### Feature Flag Override ``` -maui_invoke typeName="MyApp.FeatureFlags" methodName="Enable" argsJson='["dark-mode"]' -maui_invoke typeName="MyApp.FeatureFlags" methodName="Enable" argsJson='["experimental-ui"]' +maui_invoke_action actionName="set-feature-flag" argsJson='["dark-mode", true]' +maui_invoke_action actionName="set-feature-flag" argsJson='["experimental-ui", true]' ``` -### Navigate to Deep Screen +### Navigate to a Deep Screen ``` maui_invoke_action actionName="navigate-to" argsJson='["//settings/advanced/network"]' diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs index 8ad8de80b..da7657328 100644 --- a/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs +++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Mcp/Tools/InvokeTools.cs @@ -78,82 +78,4 @@ public static async Task InvokeAction( : $"Action '{actionName}' failed: {result.Error}"; } - [McpServerTool(Name = "maui_invoke"), Description(""" - Invoke any public method on a type in the running app via reflection. - Use this when you need to call a method that isn't registered as a DevFlow Action, - such as a static helper method you've identified in the app's source code. - - Supports two resolution modes: - - "static" (default): Calls a static method on the specified type. - - "service": Resolves the type from the app's DI container, then calls an instance method. - - Arguments are a JSON array of values matching parameter order. Types are auto-converted. - - Example (static): Invoke MyApp.DebugHelpers.ResetDatabase() - typeName: "MyApp.DebugHelpers" - methodName: "ResetDatabase" - - Example (DI service): Call LoginAsync on IAuthService - typeName: "MyApp.Services.IAuthService" - methodName: "LoginAsync" - argsJson: '["user@test.com", "pass123"]' - resolve: "service" - - This tool only invokes methods by type name or DI-resolved service type. - For registered shortcuts, use maui_invoke_action. - """)] - public static async Task Invoke( - McpAgentSession session, - [Description("Fully-qualified or simple type name (e.g., 'MyApp.DebugHelpers' or 'DebugHelpers')")] string typeName, - [Description("Method name to invoke (case-insensitive)")] string methodName, - [Description("JSON array of arguments. Example: '[\"hello\", 42, true]'")] string? argsJson = null, - [Description("Resolution mode: 'static' (default) for static methods, 'service' to resolve from DI container")] string? resolve = null, - [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) - { - JsonArray? args = null; - if (!string.IsNullOrWhiteSpace(argsJson)) - { - try - { - var node = JsonNode.Parse(argsJson); - if (node is not JsonArray array) - return $"Invalid argsJson: expected a JSON array, got {node?.GetValueKind().ToString() ?? "null"}."; - args = array; - } - catch (JsonException ex) - { - return $"Invalid JSON in argsJson: {ex.Message}"; - } - } - - var agent = await session.GetAgentClientAsync(agentPort); - var result = await agent.InvokeAsync(typeName, methodName, args, resolve); - - if (result == null) - return $"Failed to invoke {typeName}.{methodName}. Verify the app is running and the agent supports invoke."; - - return result.Success - ? $"Invoked {typeName}.{methodName}().{(result.ReturnValue != null ? $" Result: {result.ReturnValue}" : "")}" - : $"Invoke failed: {result.Error}"; - } - - [McpServerTool(Name = "maui_list_methods"), Description(""" - Discover public methods on a type in the running app. Returns method signatures with - parameter names, types, descriptions, and default values. - - Use this to explore what methods are available on a type before calling maui_invoke. - Methods annotated with [DevFlowAction] are flagged in the results. - - Example: List methods on a debug helper class - typeName: "MyApp.DebugHelpers" - """)] - public static async Task ListMethods( - McpAgentSession session, - [Description("Fully-qualified or simple type name to discover methods on")] string typeName, - [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) - { - var agent = await session.GetAgentClientAsync(agentPort); - var result = await agent.ListMethodsAsync(typeName); - return CliJson.SerializeUntyped(result, indented: false); - } } diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index bda77ccf5..9cad1cceb 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.ComponentModel; using System.Reflection; using System.Reflection.Metadata; @@ -29,26 +28,24 @@ internal static void UpdateApplication(Type[]? updatedTypes) public partial class DevFlowAgentService { private static volatile Lazy s_cachedActions = new(ScanActions, LazyThreadSafetyMode.ExecutionAndPublication); - private static readonly ConcurrentDictionary s_typeResolutionCache = new(StringComparer.OrdinalIgnoreCase); #region Action Discovery private InvokeActionEntry[] DiscoverActions() => s_cachedActions.Value; /// - /// Invalidates cached reflection data. The next call to DiscoverActions() - /// will rescan all loaded assemblies, and type resolution will rescan as needed. + /// Invalidates cached action metadata. The next call to DiscoverActions() + /// will rescan all loaded assemblies. /// Called by AssemblyLoad handler and MetadataUpdateHandler (Hot Reload). /// internal static void InvalidateActionCache() { s_cachedActions = new Lazy(ScanActions, LazyThreadSafetyMode.ExecutionAndPublication); - s_typeResolutionCache.Clear(); } private void OnAssemblyLoaded(object? sender, AssemblyLoadEventArgs args) { - if (args.LoadedAssembly.IsDynamic || IsFrameworkAssembly(args.LoadedAssembly)) + if (args.LoadedAssembly.IsDynamic) return; InvalidateActionCache(); @@ -60,7 +57,7 @@ private static InvokeActionEntry[] ScanActions() foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { - if (asm.IsDynamic || IsFrameworkAssembly(asm)) + if (asm.IsDynamic) continue; Type[] types; @@ -107,75 +104,6 @@ private static InvokeActionEntry[] ScanActions() .ToArray(); } - private static readonly Lazy> s_trustedPlatformAssemblyNames = new( - GetTrustedPlatformAssemblyNames, - LazyThreadSafetyMode.ExecutionAndPublication); - - private static bool IsFrameworkAssembly(Assembly asm) - { - var name = asm.GetName().Name; - if (string.IsNullOrEmpty(name)) - return true; - - return s_trustedPlatformAssemblyNames.Value.Contains(name) - || IsExplicitlyBlockedAssembly(name); - } - - private static HashSet GetTrustedPlatformAssemblyNames() - { - var trustedPlatformAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; - var assemblyNames = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (string.IsNullOrEmpty(trustedPlatformAssemblies)) - return assemblyNames; - - // Determine the shared framework directory so we only treat assemblies - // shipped with the runtime as "framework". TPA may also include app - // assemblies (e.g. in test runners), which we must NOT filter out. - var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location); - - foreach (var path in trustedPlatformAssemblies.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - // Only include assemblies that live in the shared framework directory - if (runtimeDir != null && !path.StartsWith(runtimeDir, StringComparison.OrdinalIgnoreCase)) - continue; - - var assemblyName = Path.GetFileNameWithoutExtension(path); - if (!string.IsNullOrEmpty(assemblyName)) - assemblyNames.Add(assemblyName); - } - - return assemblyNames; - } - - private static bool IsExplicitlyBlockedAssembly(string name) - { - return name.StartsWith("Fizzler", StringComparison.Ordinal) - || name.StartsWith("SkiaSharp", StringComparison.Ordinal) - || IsMicrosoftMauiFrameworkAssembly(name) - || IsDevFlowAssembly(name) - || name.StartsWith("Microsoft.CSharp", StringComparison.Ordinal) - || name.StartsWith("Microsoft.Win32", StringComparison.Ordinal); - } - - private static bool IsMicrosoftMauiFrameworkAssembly(string name) - { - return string.Equals(name, "Microsoft.Maui", StringComparison.Ordinal) - || (name.StartsWith("Microsoft.Maui.", StringComparison.Ordinal) - && !name.StartsWith("Microsoft.Maui.DevFlow.", StringComparison.Ordinal)); - } - - private static bool IsDevFlowAssembly(string name) - { - return string.Equals(name, "Microsoft.Maui.DevFlow.Agent", StringComparison.Ordinal) - || string.Equals(name, "Microsoft.Maui.DevFlow.Agent.Core", StringComparison.Ordinal) - || string.Equals(name, "Microsoft.Maui.DevFlow.Agent.Gtk", StringComparison.Ordinal) - || string.Equals(name, "Microsoft.Maui.DevFlow.Blazor", StringComparison.Ordinal) - || string.Equals(name, "Microsoft.Maui.DevFlow.Blazor.Gtk", StringComparison.Ordinal) - || string.Equals(name, "Microsoft.Maui.DevFlow.Driver", StringComparison.Ordinal) - || string.Equals(name, "Microsoft.Maui.DevFlow.Logging", StringComparison.Ordinal); - } - private static InvokeParameterInfo[] BuildParameterInfoList(MethodInfo method) { return method.GetParameters().Select(p => new InvokeParameterInfo @@ -229,74 +157,6 @@ private static string FormatParameterTypeName(Type type) #endregion - #region Type Resolution - - private Type? ResolveType(string typeName) - { - if (s_typeResolutionCache.TryGetValue(typeName, out var cached)) - return cached; - - // Try fully-qualified name first - var type = Type.GetType(typeName); - if (type != null) - { - if (IsFrameworkAssembly(type.Assembly)) - type = null; - else - { - s_typeResolutionCache.TryAdd(typeName, type); - return type; - } - } - - // Scan loaded assemblies - var matches = new List(); - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) - { - if (asm.IsDynamic || IsFrameworkAssembly(asm)) continue; - - // Full name match (preferred) - type = asm.GetType(typeName, throwOnError: false, ignoreCase: true); - if (type != null) - { - s_typeResolutionCache.TryAdd(typeName, type); - return type; - } - - // Simple name match (fallback for unqualified names) - if (!typeName.Contains('.')) - { - try - { - foreach (var t in asm.GetTypes()) - { - if (string.Equals(t.Name, typeName, StringComparison.OrdinalIgnoreCase)) - matches.Add(t); - } - } - catch { } - } - } - - // Deduplicate: if all matches refer to the same type, use it - var distinct = matches.Select(t => t.FullName).Distinct().ToList(); - if (distinct.Count == 1) - { - s_typeResolutionCache.TryAdd(typeName, matches[0]); - return matches[0]; - } - - if (distinct.Count > 1) - { - System.Diagnostics.Debug.WriteLine( - $"[Microsoft.Maui.DevFlow] Warning: Ambiguous type name '{typeName}' matched {distinct.Count} types: {string.Join(", ", distinct)}. Use a fully-qualified type name to resolve the ambiguity."); - } - - return null; - } - - #endregion - #region Parameter Conversion private static object? ConvertInvokeArg(Type targetType, JsonElement argElement) @@ -415,127 +275,6 @@ private static string FormatParameterTypeName(Type type) return result; } - private static int? ScoreInvokeCandidate(MethodInfo method, JsonElement[]? args) - { - var parameters = method.GetParameters(); - var argCount = args?.Length ?? 0; - var required = parameters.Count(p => !p.HasDefaultValue); - if (argCount < required || argCount > parameters.Length) - return null; - - var score = argCount == parameters.Length ? 1 : 0; - for (var i = 0; i < argCount; i++) - { - var argScore = ScoreInvokeArg(parameters[i].ParameterType, args![i]); - if (argScore == null) - return null; - - score += argScore.Value; - } - - return score; - } - - private static int? ScoreInvokeArg(Type targetType, JsonElement argElement) - { - var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; - - if (argElement.ValueKind == JsonValueKind.Null) - return Nullable.GetUnderlyingType(targetType) != null || !targetType.IsValueType ? 6 : null; - - if (underlying == typeof(string)) - return argElement.ValueKind == JsonValueKind.String ? 6 : null; - - if (underlying == typeof(bool)) - return argElement.ValueKind switch - { - JsonValueKind.True or JsonValueKind.False => 6, - JsonValueKind.String => bool.TryParse(argElement.GetString(), out _) ? 2 : null, - _ => null - }; - - var numericScore = ScoreNumericInvokeArg(underlying, argElement); - if (numericScore != null) - return numericScore; - - if (underlying.IsEnum) - { - if (argElement.ValueKind == JsonValueKind.String) - return Enum.TryParse(underlying, argElement.GetString(), ignoreCase: true, out _) ? 4 : null; - if (argElement.ValueKind == JsonValueKind.Number) - return argElement.TryGetInt64(out _) ? 2 : null; - return null; - } - - if (argElement.ValueKind == JsonValueKind.Array && TryGetInvokeCollectionElementType(underlying, out var elementType)) - { - var score = 3; - foreach (var item in argElement.EnumerateArray()) - { - var itemScore = ScoreInvokeArg(elementType, item); - if (itemScore == null) - return null; - score += Math.Min(itemScore.Value, 4); - } - return score; - } - - return underlying == typeof(object) && argElement.ValueKind == JsonValueKind.String ? 1 : null; - } - - private static int? ScoreNumericInvokeArg(Type underlying, JsonElement argElement) - { - if (argElement.ValueKind == JsonValueKind.Number) - { - if (underlying == typeof(int)) return argElement.TryGetInt32(out _) ? 6 : null; - if (underlying == typeof(long)) return argElement.TryGetInt64(out _) ? 6 : null; - if (underlying == typeof(short)) return argElement.TryGetInt16(out _) ? 6 : null; - if (underlying == typeof(byte)) return argElement.TryGetByte(out _) ? 6 : null; - if (underlying == typeof(float)) return argElement.TryGetSingle(out _) ? 6 : null; - if (underlying == typeof(double)) return argElement.TryGetDouble(out _) ? 6 : null; - if (underlying == typeof(decimal)) return argElement.TryGetDecimal(out _) ? 6 : null; - } - - if (argElement.ValueKind != JsonValueKind.String) - return null; - - var value = argElement.GetString(); - if (underlying == typeof(int)) return int.TryParse(value, out _) ? 2 : null; - if (underlying == typeof(long)) return long.TryParse(value, out _) ? 2 : null; - if (underlying == typeof(short)) return short.TryParse(value, out _) ? 2 : null; - if (underlying == typeof(byte)) return byte.TryParse(value, out _) ? 2 : null; - if (underlying == typeof(float)) return float.TryParse(value, out _) ? 2 : null; - if (underlying == typeof(double)) return double.TryParse(value, out _) ? 2 : null; - if (underlying == typeof(decimal)) return decimal.TryParse(value, out _) ? 2 : null; - - return null; - } - - private static bool TryGetInvokeCollectionElementType(Type type, out Type elementType) - { - if (type.IsArray) - { - elementType = type.GetElementType()!; - return true; - } - - if (type.IsGenericType) - { - var def = type.GetGenericTypeDefinition(); - if (def == typeof(List<>) || def == typeof(IList<>) || def == typeof(IEnumerable<>) || def == typeof(IReadOnlyList<>) || def == typeof(ICollection<>) || def == typeof(IReadOnlyCollection<>)) - { - elementType = type.GetGenericArguments()[0]; - return true; - } - } - - elementType = typeof(object); - return false; - } - - private static string FormatInvokeMethodSignature(MethodInfo method) => - $"{method.Name}({string.Join(", ", method.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})"; - #endregion #region Invoke Execution @@ -613,19 +352,6 @@ private async Task DispatchInvokeMethodAsync(MethodInfo meth return result!; } - private static bool IsElementInvokeAllowedMethod(MethodInfo method) - { - var declaringType = method.DeclaringType; - if (declaringType == null || IsFrameworkAssembly(declaringType.Assembly)) - return false; - - var assemblyName = declaringType.Assembly.GetName().Name; - return !string.Equals(assemblyName, "Microsoft.Maui", StringComparison.Ordinal) - && (assemblyName == null - || !assemblyName.StartsWith("Microsoft.Maui.", StringComparison.Ordinal) - || assemblyName.StartsWith("Microsoft.Maui.DevFlow.", StringComparison.Ordinal)); - } - #endregion #region HTTP Handlers @@ -673,8 +399,7 @@ private async Task HandleInvokeAction(HttpRequest request) try { var convertedArgs = ConvertInvokeArgs(action.Method.GetParameters(), args); - var invokeTask = await DispatchAsync(() => InvokeMethodAsync(action.Method, null, convertedArgs)); - var (success, returnValue, returnType, error) = await invokeTask; + var (success, returnValue, returnType, error) = await DispatchInvokeMethodAsync(action.Method, null, convertedArgs); return success ? HttpResponse.Json(new { success = true, action = action.Name, returnValue, returnType }) @@ -686,183 +411,6 @@ private async Task HandleInvokeAction(HttpRequest request) } } - private async Task HandleInvoke(HttpRequest request) - { - var body = request.BodyAs(); - if (body?.TypeName == null) - return InvokeError("typeName is required"); - if (body.MethodName == null) - return InvokeError("methodName is required"); - - var type = ResolveType(body.TypeName); - if (type == null) - return InvokeError($"Type '{body.TypeName}' not found in loaded assemblies."); - - var resolve = body.Resolve ?? "static"; - var isService = string.Equals(resolve, "service", StringComparison.OrdinalIgnoreCase); - - var bindingFlags = BindingFlags.Public | BindingFlags.IgnoreCase - | (isService ? BindingFlags.Instance : BindingFlags.Static); - - // Always enumerate methods by name to avoid AmbiguousMatchException on overloads - var candidates = type.GetMethods(bindingFlags) - .Where(m => string.Equals(m.Name, body.MethodName, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - if (candidates.Length == 0) - return InvokeError($"Method '{body.MethodName}' not found on type '{type.FullName}'."); - - var argCount = body.Args?.Length ?? 0; - var scored = candidates - .Select(m => new { Method = m, Score = ScoreInvokeCandidate(m, body.Args) }) - .Where(m => m.Score != null) - .ToArray(); - - if (scored.Length == 0) - { - var signatures = string.Join(", ", candidates.Select(FormatInvokeMethodSignature)); - return InvokeError($"No overload of '{body.MethodName}' on type '{type.FullName}' matches {argCount} argument(s). Candidates: {signatures}"); - } - - var bestScore = scored.Max(m => m.Score!.Value); - var matched = scored.Where(m => m.Score == bestScore).Select(m => m.Method).ToArray(); - - if (matched.Length > 1) - { - var signatures = string.Join(", ", matched.Select(FormatInvokeMethodSignature)); - return InvokeError($"Ambiguous method '{body.MethodName}' on type '{type.FullName}' - {matched.Length} overloads match {argCount} argument(s). Use a fully-qualified type or adjust arguments. Candidates: {signatures}"); - } - - var method = matched[0]; - - object? target = null; - if (isService) - { - target = await DispatchAsync(() => - { - var sp = _app?.Handler?.MauiContext?.Services; - return sp?.GetService(type); - }); - - if (target == null) - return InvokeError($"Could not resolve type '{type.FullName}' from DI container. Ensure it is registered in the app's service collection."); - } - - try - { - var convertedArgs = ConvertInvokeArgs(method.GetParameters(), body.Args); - - bool success; string? returnValue; string? returnType; string? error; - if (isService) - { - // Service invoke must run on UI thread since the service may access UI state - (success, returnValue, returnType, error) = await DispatchInvokeMethodAsync(method, target, convertedArgs); - } - else - { - var invokeTask = await DispatchAsync(() => InvokeMethodAsync(method, target, convertedArgs)); - (success, returnValue, returnType, error) = await invokeTask; - } - - return success - ? HttpResponse.Json(new { success = true, typeName = type.FullName, methodName = method.Name, returnValue, returnType }) - : InvokeError($"Invoke failed: {error}"); - } - catch (Exception ex) - { - return InvokeError($"Argument error: {ex.Message}"); - } - } - - private async Task HandleElementInvoke(HttpRequest request) - { - if (_app == null) return InvokeError("Agent not bound to app"); - if (!request.RouteParams.TryGetValue("id", out var id)) - return InvokeError("Element ID required"); - - var body = request.BodyAs(); - if (body?.MethodName == null) - return InvokeError("methodName is required"); - - // Resolve element and method on the UI thread - var resolution = await DispatchAsync(() => - { - var el = _treeWalker.GetElementById(id, _app); - if (el == null) - return (element: (object?)null, method: (MethodInfo?)null, error: (string?)$"Element '{id}' not found"); - - var type = el.GetType(); - var method = type.GetMethod(body.MethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); - if (method == null) - return (element: (object?)null, method: (MethodInfo?)null, error: (string?)$"Method '{body.MethodName}' not found on element type '{type.Name}'"); - if (!IsElementInvokeAllowedMethod(method)) - return (element: (object?)null, method: (MethodInfo?)null, error: (string?)$"Method '{body.MethodName}' on element type '{type.Name}' is not invocable because it is declared by framework type '{method.DeclaringType?.FullName}'."); - - return (element: (object?)el, method: (MethodInfo?)method, error: (string?)null); - }); - - if (resolution.error != null) - return InvokeError(resolution.error); - - try - { - var convertedArgs = ConvertInvokeArgs(resolution.method!.GetParameters(), body.Args); - - // Invoke on the UI thread and await the result (handles both sync and async methods) - var invokeTask = await DispatchAsync(() => InvokeMethodAsync(resolution.method!, resolution.element, convertedArgs)); - var (success, returnValue, returnType, error) = await invokeTask; - - return success - ? HttpResponse.Json(new { success = true, elementId = id, methodName = body.MethodName, returnValue, returnType }) - : InvokeError($"Element invoke failed: {error}"); - } - catch (ArgumentException ex) - { - return InvokeError($"Argument error: {ex.Message}"); - } - catch (Exception ex) - { - return InvokeError($"Element invoke failed: {ex.Message}"); - } - } - - private Task HandleListMethods(HttpRequest request) - { - if (!request.QueryParams.TryGetValue("typeName", out var typeName) || string.IsNullOrWhiteSpace(typeName)) - return Task.FromResult(HttpResponse.Error("Query parameter 'typeName' is required")); - - var type = ResolveType(typeName); - if (type == null) - return Task.FromResult(HttpResponse.NotFound($"Type '{typeName}' not found in loaded assemblies.")); - - var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) - .Where(m => !m.IsSpecialName) // exclude property getters/setters, event add/remove - .Select(m => - { - var actionAttr = m.GetCustomAttribute(); - return new - { - name = m.Name, - returnType = FormatParameterTypeName(m.ReturnType), - isStatic = m.IsStatic, - isAsync = typeof(Task).IsAssignableFrom(m.ReturnType) - || m.ReturnType == typeof(ValueTask) - || (m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)), - devFlowActionName = actionAttr?.Name, - parameters = m.GetParameters().Select(p => new - { - name = p.Name, - type = FormatParameterTypeName(p.ParameterType), - description = p.GetCustomAttribute()?.Description, - defaultValue = p.HasDefaultValue ? FormatDefaultValue(p.DefaultValue) : null, - isRequired = !p.HasDefaultValue - }) - }; - }); - - return Task.FromResult(HttpResponse.Json(new { typeName = type.FullName, methods })); - } - #endregion #region DTOs @@ -890,21 +438,7 @@ private sealed record InvokeMethodResult(bool Success, string? ReturnValue, stri #endregion } -public class InvokeRequest -{ - public string? TypeName { get; set; } - public string? MethodName { get; set; } - public JsonElement[]? Args { get; set; } - public string? Resolve { get; set; } -} - public class InvokeActionRequest { public JsonElement[]? Args { get; set; } } - -public class ElementInvokeRequest -{ - public string? MethodName { get; set; } - public JsonElement[]? Args { get; set; } -} diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs index e15fa21f6..130bd80a9 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.cs @@ -557,9 +557,6 @@ private void RegisterRoutes() // Invoke / reflection _server.MapGet("/api/v1/invoke/actions", HandleListActions); _server.MapPost("/api/v1/invoke/actions/{name}", HandleInvokeAction); - _server.MapPost("/api/v1/invoke", HandleInvoke); - _server.MapGet("/api/v1/invoke/methods", HandleListMethods); - _server.MapPost("/api/v1/ui/elements/{id}/invoke", HandleElementInvoke); } private async Task HandleStatus(HttpRequest request) @@ -712,7 +709,7 @@ private Task HandleCapabilities(HttpRequest request) invoke = new { supported = true, - features = new[] { "actions", "static", "service", "element", "discover" } + features = new[] { "actions" } } }; @@ -2026,11 +2023,7 @@ private sealed class BatchActionRequest public string? Direction { get; set; } public double Distance { get; set; } = 120; public int DurationMs { get; set; } = 200; - // Invoke-related properties - public string? TypeName { get; set; } - public string? MethodName { get; set; } public JsonElement[]? Args { get; set; } - public string? Resolve { get; set; } public string? Name { get; set; } } @@ -2311,19 +2304,6 @@ private async Task HandleBatch(HttpRequest request) Body = JsonSerializer.Serialize(new SetPropertyRequest { Value = action.Value ?? string.Empty }) }); break; - case "invoke": - response = await HandleInvoke(new HttpRequest - { - Method = "POST", - Body = JsonSerializer.Serialize(new InvokeRequest - { - TypeName = action.TypeName, - MethodName = action.MethodName, - Args = action.Args, - Resolve = action.Resolve - }) - }); - break; case "invoke-action": case "invoke_action": response = await HandleInvokeAction(new HttpRequest diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs index 2b50f579a..251c7e737 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Driver/AgentClient.cs @@ -558,7 +558,7 @@ private async Task DeleteActionAsync(string path) } } - // ── Invoke / Reflection ── + // ── DevFlow Actions ── private const string InvokeApi = $"{ApiV1}/invoke"; @@ -579,43 +579,6 @@ public async Task ListActionsAsync() return await PostJsonAsync($"{InvokeApi}/actions/{Uri.EscapeDataString(actionName)}", body); } - /// - /// Invoke a method by type name and method name via reflection. - /// - public async Task InvokeAsync(string typeName, string methodName, JsonArray? args = null, string? resolve = null) - { - var body = new JsonObject - { - ["typeName"] = typeName, - ["methodName"] = methodName - }; - if (args != null) - body["args"] = args; - if (resolve != null) - body["resolve"] = resolve; - return await PostJsonAsync($"{InvokeApi}", body); - } - - /// - /// Invoke a method on a visual tree element. - /// - public async Task InvokeElementMethodAsync(string elementId, string methodName, JsonArray? args = null) - { - var body = new JsonObject - { - ["methodName"] = methodName - }; - if (args != null) - body["args"] = args; - return await PostJsonAsync($"{UiApi}/elements/{elementId}/invoke", body); - } - - /// - /// Discover public methods on a type. - /// - public async Task ListMethodsAsync(string typeName) - => await GetJsonAsync($"{InvokeApi}/methods?typeName={Uri.EscapeDataString(typeName)}"); - // ── Preferences ── public async Task GetPreferencesAsync(string? sharedName = null) @@ -1228,7 +1191,7 @@ public class ProfilerCapabilities } /// -/// Result of an invoke operation. +/// Result of a DevFlow Action invocation. /// public class InvokeResult { @@ -1242,10 +1205,4 @@ public class InvokeResult public string? Error { get; set; } [System.Text.Json.Serialization.JsonPropertyName("action")] public string? Action { get; set; } - [System.Text.Json.Serialization.JsonPropertyName("typeName")] - public string? TypeName { get; set; } - [System.Text.Json.Serialization.JsonPropertyName("methodName")] - public string? MethodName { get; set; } - [System.Text.Json.Serialization.JsonPropertyName("elementId")] - public string? ElementId { get; set; } } diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs index 3a4ab05e6..dadec4732 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Tests/InvokeTests.cs @@ -1,9 +1,8 @@ -using System.Collections.Concurrent; -using System.ComponentModel; using System.Net; using System.Net.Sockets; using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Maui; using Microsoft.Maui.Controls; using Microsoft.Maui.DevFlow.Agent.Core; @@ -21,26 +20,22 @@ public async Task ListActions_DiscoversMethods_WithDevFlowActionAttribute() var actions = await harness.Client.ListActionsAsync(); - var json = actions; - Assert.Equal(JsonValueKind.Object, json.ValueKind); - - var actionsArray = json.GetProperty("actions"); + Assert.Equal(JsonValueKind.Object, actions.ValueKind); + var actionsArray = actions.GetProperty("actions"); Assert.Equal(JsonValueKind.Array, actionsArray.ValueKind); - // Find our test action var testAction = actionsArray.EnumerateArray() .FirstOrDefault(a => a.GetProperty("name").GetString() == "test-greet"); + Assert.NotEqual(default, testAction); Assert.Equal("Returns a greeting for the given name", testAction.GetProperty("description").GetString()); - // Verify parameter metadata - var parameters = testAction.GetProperty("parameters"); - Assert.Equal(JsonValueKind.Array, parameters.ValueKind); - - var nameParam = parameters.EnumerateArray().First(); + var nameParam = testAction.GetProperty("parameters").EnumerateArray().First(); Assert.Equal("name", nameParam.GetProperty("name").GetString()); Assert.Equal("string", nameParam.GetProperty("type").GetString()); Assert.Equal("The name to greet", nameParam.GetProperty("description").GetString()); + Assert.Equal("Friend", nameParam.GetProperty("defaultValue").GetString()); + Assert.False(nameParam.GetProperty("isRequired").GetBoolean()); } [Fact] @@ -52,8 +47,9 @@ public async Task InvokeAction_CallsRegisteredAction_ReturnsResult() JsonArray(JsonElement("World"))); Assert.NotNull(result); - Assert.True(result.Success); + Assert.True(result.Success, result.Error); Assert.Equal("Hello, World!", result.ReturnValue); + Assert.Equal("test-greet", result.Action); } [Fact] @@ -64,271 +60,135 @@ public async Task InvokeAction_WithDefaultParameters_UsesDefaults() var result = await harness.Client.InvokeActionAsync("test-greet"); Assert.NotNull(result); - Assert.True(result.Success); + Assert.True(result.Success, result.Error); Assert.Equal("Hello, Friend!", result.ReturnValue); } [Fact] - public async Task Invoke_CallsStaticMethod_ByTypeName() + public async Task InvokeAction_NotFound_ReturnsError() { using var harness = await InvokeTestHarness.CreateAsync(); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.Add), - JsonArray(JsonElement(3), JsonElement(4))); + var result = await harness.Client.InvokeActionAsync("nonexistent-action"); Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("7", result.ReturnValue); + Assert.False(result.Success); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Invoke_CallsAsyncMethod_AwaitsResult() + public async Task InvokeAction_CallsAsyncMethod_AwaitsResult() { using var harness = await InvokeTestHarness.CreateAsync(); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.GetValueAsync), + var result = await harness.Client.InvokeActionAsync( + "test-async", JsonArray(JsonElement("test-value"))); Assert.NotNull(result); - Assert.True(result.Success); + Assert.True(result.Success, result.Error); Assert.Equal("async:test-value", result.ReturnValue); } [Fact] - public async Task Invoke_CallsVoidMethod_ReturnsOk() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - TestInvokeHelpers.LastSideEffect = null; - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.DoSideEffect), - JsonArray(JsonElement("done"))); - - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("done", TestInvokeHelpers.LastSideEffect); - } - - [Fact] - public async Task Invoke_WithBoolParameter_ConvertsCorrectly() + public async Task InvokeAction_CallsValueTaskMethod_AwaitsResult() { using var harness = await InvokeTestHarness.CreateAsync(); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.IsEnabled), - JsonArray(JsonElement(true))); - - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("True", result.ReturnValue); - } - - [Fact] - public async Task Invoke_MethodNotFound_ReturnsError() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - "NonExistentMethod"); + var result = await harness.Client.InvokeActionAsync( + "test-value-task", + JsonArray(JsonElement("test-value"))); Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.True(result.Success, result.Error); + Assert.Equal("value-task:test-value", result.ReturnValue); } [Fact] - public async Task Invoke_TypeNotFound_ReturnsError() + public async Task InvokeAction_CallsVoidMethod_ReturnsVoid() { using var harness = await InvokeTestHarness.CreateAsync(); - var result = await harness.Client.InvokeAsync( - "Some.Nonexistent.Type", - "SomeMethod"); + TestInvokeHelpers.LastSideEffect = null; + var result = await harness.Client.InvokeActionAsync( + "test-side-effect", + JsonArray(JsonElement("done"))); Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void InvalidateActionCache_ClearsTypeResolutionCache() - { - var cacheField = typeof(DevFlowAgentService).GetField("s_typeResolutionCache", BindingFlags.NonPublic | BindingFlags.Static); - Assert.NotNull(cacheField); - var cache = Assert.IsType>(cacheField.GetValue(null)); - - const string cacheKey = "__hot_reload_cache_test__"; - cache[cacheKey] = typeof(TestInvokeHelpers); - - var invalidateMethod = typeof(DevFlowAgentService).GetMethod("InvalidateActionCache", BindingFlags.NonPublic | BindingFlags.Static); - Assert.NotNull(invalidateMethod); - invalidateMethod.Invoke(null, null); - - Assert.False(cache.ContainsKey(cacheKey)); - } - - [Theory] - [InlineData("Microsoft.Maui", true)] - [InlineData("Microsoft.Maui.Controls", true)] - [InlineData("Microsoft.Maui.DevFlow.Agent.Core", true)] - [InlineData("Microsoft.CSharp", true)] - [InlineData("Microsoft.Win32.Registry", true)] - [InlineData("Microsoft.Maui.DevFlow.Tests", false)] - public void IsExplicitlyBlockedAssembly_FiltersFrameworkAndDevFlowAssemblies(string assemblyName, bool expected) - { - var isBlockedMethod = typeof(DevFlowAgentService).GetMethod("IsExplicitlyBlockedAssembly", BindingFlags.NonPublic | BindingFlags.Static); - Assert.NotNull(isBlockedMethod); - - var actual = Assert.IsType(isBlockedMethod.Invoke(null, new object[] { assemblyName })); - - Assert.Equal(expected, actual); - } - - [Fact] - public async Task ListMethods_ReturnsPublicMethods_ForType() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - var result = await harness.Client.ListMethodsAsync(typeof(TestInvokeHelpers).FullName!); - - Assert.NotEqual(default, result); - Assert.Equal(JsonValueKind.Object, result.ValueKind); - - var methods = result.GetProperty("methods"); - Assert.Equal(JsonValueKind.Array, methods.ValueKind); - - var methodNames = methods.EnumerateArray() - .Select(m => m.GetProperty("name").GetString()) - .ToList(); - - Assert.Contains("Greet", methodNames); - Assert.Contains("Add", methodNames); - Assert.Contains("GetValueAsync", methodNames); - Assert.Contains("DoSideEffect", methodNames); + Assert.True(result.Success, result.Error); + Assert.Equal("done", TestInvokeHelpers.LastSideEffect); + Assert.Equal("void", result.ReturnType); } [Fact] - public async Task InvokeAction_NotFound_ReturnsError() + public async Task InvokeAction_WithBoolParameter_ConvertsCorrectly() { using var harness = await InvokeTestHarness.CreateAsync(); - var result = await harness.Client.InvokeActionAsync("nonexistent-action"); + var result = await harness.Client.InvokeActionAsync( + "test-bool", + JsonArray(JsonElement(true))); Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Batch_WithInvokeFailure_StopsAndReportsFailure() - { - using var harness = await InvokeTestHarness.CreateAsync(); - TestInvokeHelpers.LastSideEffect = null; - - var result = await harness.Client.BatchAsync( - [ - new System.Text.Json.Nodes.JsonObject - { - ["action"] = "invoke", - ["typeName"] = typeof(TestInvokeHelpers).FullName, - ["methodName"] = "NonExistentMethod" - }, - new System.Text.Json.Nodes.JsonObject - { - ["action"] = "invoke", - ["typeName"] = typeof(TestInvokeHelpers).FullName, - ["methodName"] = nameof(TestInvokeHelpers.DoSideEffect), - ["args"] = JsonArray(JsonElement("should-not-run")) - } - ], - continueOnError: false); - - Assert.False(result.GetProperty("success").GetBoolean()); - var onlyResult = Assert.Single(result.GetProperty("results").EnumerateArray()); - Assert.False(onlyResult.GetProperty("success").GetBoolean()); - Assert.Equal(400, onlyResult.GetProperty("statusCode").GetInt32()); - Assert.Null(TestInvokeHelpers.LastSideEffect); + Assert.True(result.Success, result.Error); + Assert.Equal("True", result.ReturnValue); } [Fact] - public async Task Invoke_WithArrayParameter_ConvertsJsonArray() + public async Task InvokeAction_WithArrayParameter_ConvertsJsonArray() { using var harness = await InvokeTestHarness.CreateAsync(); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.JoinNumbers), + var result = await harness.Client.InvokeActionAsync( + "test-join-numbers", JsonArray(JsonElement(new[] { 1, 2, 3 }))); Assert.NotNull(result); - Assert.True(result.Success); + Assert.True(result.Success, result.Error); Assert.Equal("1,2,3", result.ReturnValue); } - [Fact] - public async Task Invoke_WithEnumParameter_ConvertsStringToEnum() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.GetPriority), - JsonArray(JsonElement("High"))); - - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("High", result.ReturnValue); - } - - [Fact] - public async Task Invoke_WithEnumParameter_CaseInsensitive() + [Theory] + [InlineData("High", "High")] + [InlineData("medium", "Medium")] + public async Task InvokeAction_WithEnumParameter_ConvertsStringToEnum(string input, string expected) { using var harness = await InvokeTestHarness.CreateAsync(); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.GetPriority), - JsonArray(JsonElement("medium"))); + var result = await harness.Client.InvokeActionAsync( + "test-priority", + JsonArray(JsonElement(input))); Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("Medium", result.ReturnValue); + Assert.True(result.Success, result.Error); + Assert.Equal(expected, result.ReturnValue); } [Fact] - public async Task Invoke_WithNullableParameter_PassesValue() + public async Task InvokeAction_WithNullableParameter_PassesValue() { using var harness = await InvokeTestHarness.CreateAsync(); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.FormatNullable), + var result = await harness.Client.InvokeActionAsync( + "test-nullable", JsonArray(JsonElement(42))); Assert.NotNull(result); - Assert.True(result.Success); + Assert.True(result.Success, result.Error); Assert.Equal("42", result.ReturnValue); } [Fact] - public async Task Invoke_WithNullableParameter_PassesNull() + public async Task InvokeAction_WithNullableParameter_PassesNull() { using var harness = await InvokeTestHarness.CreateAsync(); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.FormatNullable), + var result = await harness.Client.InvokeActionAsync( + "test-nullable", JsonArray(JsonElement(null))); Assert.NotNull(result); - Assert.True(result.Success); + Assert.True(result.Success, result.Error); Assert.Equal("null", result.ReturnValue); } @@ -346,38 +206,16 @@ public async Task InvokeAction_DispatchesInvocationToUiThread() Assert.True(TestInvokeHelpers.DispatchCallCount > 0); } - [Fact] - public async Task Invoke_WithStaticResolve_DispatchesInvocationToUiThread() - { - using var harness = await InvokeTestHarness.CreateWithDispatcherAsync(new DispatchRequiredDispatcher()); - - TestInvokeHelpers.ResetDispatchState(); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.GetDispatchState), - resolve: "static"); - - Assert.NotNull(result); - Assert.True(result.Success, result.Error); - Assert.Equal("dispatched", result.ReturnValue); - Assert.True(TestInvokeHelpers.DispatchCallCount > 0); - } - - // ── MCP-style integration tests ── - // These tests exercise the AgentClient methods using the same parameter patterns - // that the MCP InvokeTools pass (JSON string → JsonArray parsing, explicit resolve, etc.) - [Fact] public async Task InvokeAction_WithMcpStyleJsonArgs_ParsesAndInvokes() { using var harness = await InvokeTestHarness.CreateAsync(); - // MCP tools receive argsJson as a raw JSON string and parse it var args = ParseMcpArgsJson("[\"World\"]"); var result = await harness.Client.InvokeActionAsync("test-greet", args); Assert.NotNull(result); - Assert.True(result.Success); + Assert.True(result.Success, result.Error); Assert.Equal("Hello, World!", result.ReturnValue); } @@ -386,356 +224,87 @@ public async Task InvokeAction_WithMcpStyleMixedTypeArgs_ParsesCorrectly() { using var harness = await InvokeTestHarness.CreateAsync(); - // MCP tools pass mixed-type args as a JSON array string var args = ParseMcpArgsJson("[10, 20]"); var result = await harness.Client.InvokeActionAsync("test-add", args); - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("30", result.ReturnValue); - } - - [Fact] - public async Task InvokeAction_WithMcpStyleNullArgs_UsesDefaults() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - // MCP tools pass null when argsJson is empty/whitespace - var args = ParseMcpArgsJson(null); - var result = await harness.Client.InvokeActionAsync("test-greet", args); - - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("Hello, Friend!", result.ReturnValue); - } - - [Fact] - public async Task Invoke_WithExplicitStaticResolve_CallsStaticMethod() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - // MCP maui_invoke passes resolve: "static" explicitly - var args = ParseMcpArgsJson("[5, 7]"); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.Add), - args, - resolve: "static"); - - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("12", result.ReturnValue); - } - - [Fact] - public async Task Invoke_WithServiceResolve_NoContainer_ReturnsError() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - // MCP maui_invoke with resolve: "service" when no DI container is available. - // Uses a type with an instance method to reach the DI resolution path. - var result = await harness.Client.InvokeAsync( - typeof(TestServiceClass).FullName!, - nameof(TestServiceClass.GetValue), - resolve: "service"); - - Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("DI container", result.Error, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ListMethods_WithOverloadedMethods_ReturnsAllOverloads() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - var result = await harness.Client.ListMethodsAsync(typeof(TestInvokeHelpersWithOverloads).FullName!); - - Assert.NotEqual(default, result); - var methods = result.GetProperty("methods"); - var concatMethods = methods.EnumerateArray() - .Where(m => m.GetProperty("name").GetString() == "Concat") - .ToList(); - - // Should list both overloads - Assert.Equal(2, concatMethods.Count); - - // Verify different parameter counts - var paramCounts = concatMethods - .Select(m => m.GetProperty("parameters").GetArrayLength()) - .OrderBy(c => c) - .ToList(); - Assert.Equal(2, paramCounts[0]); - Assert.Equal(3, paramCounts[1]); - } - - [Fact] - public async Task Invoke_OverloadedMethod_ResolvesBy2ArgCount() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - // Call Concat with 2 args — should resolve to Concat(string, string) - var args = ParseMcpArgsJson("[\"hello\", \"world\"]"); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpersWithOverloads).FullName!, - "Concat", - args, - resolve: "static"); - - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("hello world", result.ReturnValue); - } - - [Fact] - public async Task Invoke_OverloadedMethod_ResolvesBy3ArgCount() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - // Call Concat with 3 args — should resolve to Concat(string, string, string) - var args = ParseMcpArgsJson("[\"a\", \"b\", \"c\"]"); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpersWithOverloads).FullName!, - "Concat", - args, - resolve: "static"); - - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("a-b-c", result.ReturnValue); - } - - [Fact] - public async Task Invoke_OverloadedMethod_ResolvesByStringArgumentType() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpersWithOverloads).FullName!, - "Choose", - JsonArray(JsonElement("42")), - resolve: "static"); - - Assert.NotNull(result); - Assert.True(result.Success, result.Error); - Assert.Equal("string:42", result.ReturnValue); - } - - [Fact] - public async Task Invoke_OverloadedMethod_ResolvesByNumberArgumentType() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpersWithOverloads).FullName!, - "Choose", - JsonArray(JsonElement(42)), - resolve: "static"); - - Assert.NotNull(result); - Assert.True(result.Success, result.Error); - Assert.Equal("int:42", result.ReturnValue); - } - - [Fact] - public async Task Invoke_OverloadedMethod_WithEquallyConvertibleArguments_ReturnsAmbiguous() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpersWithOverloads).FullName!, - "AmbiguousNumber", - JsonArray(JsonElement(42)), - resolve: "static"); - - Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("Ambiguous method", result.Error, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Int32 value", result.Error, StringComparison.Ordinal); - Assert.Contains("Int64 value", result.Error, StringComparison.Ordinal); - } - - [Fact] - public async Task Invoke_WithMcpStyleComplexArgs_ConvertsTypes() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - // MCP tools pass all args as JSON — including booleans, numbers, strings mixed - var args = ParseMcpArgsJson("[true]"); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.IsEnabled), - args); - - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("True", result.ReturnValue); - } - - [Fact] - public async Task Invoke_WithMcpStyleArrayArg_ConvertsJsonArray() - { - using var harness = await InvokeTestHarness.CreateAsync(); - - // MCP tools pass arrays nested inside the outer args array - var args = ParseMcpArgsJson("[[1, 2, 3]]"); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.JoinNumbers), - args); - - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal("1,2,3", result.ReturnValue); - } - - // ── Element Invoke Tests ── - - [Fact] - public async Task InvokeElement_CallsMethodOnTreeElement_ReturnsResult() - { - var view = new TestInvokeView { AutomationId = "test-invoke-view" }; - using var harness = await InvokeTestHarness.CreateAsync(view); - - var result = await harness.Client.InvokeElementMethodAsync( - "test-invoke-view", - nameof(TestInvokeView.TestMethod), - JsonArray(JsonElement("hello"))); - - Assert.NotNull(result); - Assert.True(result.Success, result.Error); - Assert.Equal("result:hello", result.ReturnValue); - Assert.Equal("test-invoke-view", result.ElementId); - } - - [Fact] - public async Task InvokeElement_WithMultipleArgs_ConvertsCorrectly() - { - var view = new TestInvokeView { AutomationId = "test-invoke-view" }; - using var harness = await InvokeTestHarness.CreateAsync(view); - - var result = await harness.Client.InvokeElementMethodAsync( - "test-invoke-view", - nameof(TestInvokeView.AddNumbers), - JsonArray(JsonElement(5), JsonElement(7))); - - Assert.NotNull(result); - Assert.True(result.Success, result.Error); - Assert.Equal("12", result.ReturnValue); - } - - [Fact] - public async Task InvokeElement_AsyncMethod_AwaitsAndReturnsResult() - { - var view = new TestInvokeView { AutomationId = "test-invoke-view" }; - using var harness = await InvokeTestHarness.CreateAsync(view); - - var result = await harness.Client.InvokeElementMethodAsync( - "test-invoke-view", - nameof(TestInvokeView.GetValueAsync), - JsonArray(JsonElement("test"))); - Assert.NotNull(result); Assert.True(result.Success, result.Error); - Assert.Equal("async:test", result.ReturnValue); + Assert.Equal("30", result.ReturnValue); } [Fact] - public async Task InvokeElement_ElementNotFound_ReturnsError() + public async Task Batch_WithInvokeActionFailure_StopsAndReportsFailure() { using var harness = await InvokeTestHarness.CreateAsync(); + TestInvokeHelpers.LastSideEffect = null; - var result = await harness.Client.InvokeElementMethodAsync( - "nonexistent-element", - "SomeMethod"); - - Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task InvokeElement_MethodNotFoundOnElement_ReturnsError() - { - var view = new TestInvokeView { AutomationId = "test-invoke-view" }; - using var harness = await InvokeTestHarness.CreateAsync(view); - - var result = await harness.Client.InvokeElementMethodAsync( - "test-invoke-view", - "NonExistentMethod"); - - Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task InvokeElement_FrameworkDeclaredMethod_ReturnsError() - { - var view = new TestInvokeView { AutomationId = "test-invoke-view" }; - using var harness = await InvokeTestHarness.CreateAsync(view); - - var result = await harness.Client.InvokeElementMethodAsync( - "test-invoke-view", - nameof(GetType)); + var result = await harness.Client.BatchAsync( + [ + new JsonObject + { + ["action"] = "invoke-action", + ["name"] = "nonexistent-action" + }, + new JsonObject + { + ["action"] = "invoke-action", + ["name"] = "test-side-effect", + ["args"] = JsonArray(JsonElement("should-not-run")) + } + ], + continueOnError: false); - Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("not invocable", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.False(result.GetProperty("success").GetBoolean()); + var onlyResult = Assert.Single(result.GetProperty("results").EnumerateArray()); + Assert.False(onlyResult.GetProperty("success").GetBoolean()); + Assert.Equal(400, onlyResult.GetProperty("statusCode").GetInt32()); + Assert.Null(TestInvokeHelpers.LastSideEffect); } - // ── DI Service Resolution Tests ── - [Fact] - public async Task Invoke_WithServiceResolve_InstanceMethodExists_NoHandler_ReturnsContainerError() + public async Task Batch_WithGenericInvoke_ReturnsUnsupportedAction() { - // TestService has public instance methods, so the method resolution succeeds, - // but DI resolution fails because TestApplication has no Handler/MauiContext. using var harness = await InvokeTestHarness.CreateAsync(); - var result = await harness.Client.InvokeAsync( - typeof(TestService).FullName!, - nameof(TestService.GetGreeting), - JsonArray(JsonElement("World")), - resolve: "service"); + var result = await harness.Client.BatchAsync( + [ + new JsonObject + { + ["action"] = "invoke", + ["typeName"] = typeof(TestInvokeHelpers).FullName, + ["methodName"] = "Greet" + } + ], + continueOnError: false); - Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("Could not resolve type", result.Error, StringComparison.OrdinalIgnoreCase); - Assert.Contains("DI container", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.False(result.GetProperty("success").GetBoolean()); + var onlyResult = Assert.Single(result.GetProperty("results").EnumerateArray()); + Assert.False(onlyResult.GetProperty("success").GetBoolean()); + Assert.Contains("Unsupported batch action", onlyResult.GetProperty("response").GetString(), StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Invoke_WithServiceResolve_StaticMethodNotVisible_ReturnsMethodNotFound() + public void InvalidateActionCache_ReplacesCachedActionList() { - // When resolve is "service", only instance methods are searched (BindingFlags.Instance). - // Static-only methods should yield "method not found". - using var harness = await InvokeTestHarness.CreateAsync(); + var cacheField = typeof(DevFlowAgentService).GetField("s_cachedActions", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(cacheField); + var before = cacheField.GetValue(null); - var result = await harness.Client.InvokeAsync( - typeof(TestInvokeHelpers).FullName!, - nameof(TestInvokeHelpers.Add), - JsonArray(JsonElement(1), JsonElement(2)), - resolve: "service"); + var invalidateMethod = typeof(DevFlowAgentService).GetMethod("InvalidateActionCache", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(invalidateMethod); + invalidateMethod.Invoke(null, null); - Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + var after = cacheField.GetValue(null); + Assert.NotSame(before, after); } - // TODO: Full DI service resolution test (success path) requires mocking - // IElementHandler, IMauiContext, and IServiceProvider on TestApplication.Handler. - // This would need: app.Handler = mockHandler where mockHandler.MauiContext.Services - // returns an IServiceProvider that resolves the target type. Currently only the - // error path is tested since TestApplication has no handler infrastructure. - #region Helpers - private static System.Text.Json.Nodes.JsonArray JsonArray(params JsonElement[] elements) + private static JsonArray JsonArray(params JsonElement[] elements) { - var arr = new System.Text.Json.Nodes.JsonArray(); + var arr = new JsonArray(); foreach (var e in elements) - arr.Add(System.Text.Json.Nodes.JsonNode.Parse(e.GetRawText())); + arr.Add(JsonNode.Parse(e.GetRawText())); return arr; } @@ -751,16 +320,11 @@ private static JsonElement JsonElement(T value) return JsonDocument.Parse(json).RootElement.Clone(); } - /// - /// Mimics the MCP InvokeTools argsJson parsing: takes a raw JSON string - /// and parses it into a JsonArray, exactly as the MCP tools do. - /// - private static System.Text.Json.Nodes.JsonArray? ParseMcpArgsJson(string? argsJson) + private static JsonArray? ParseMcpArgsJson(string? argsJson) { if (string.IsNullOrWhiteSpace(argsJson)) return null; - var node = System.Text.Json.Nodes.JsonNode.Parse(argsJson); - return node as System.Text.Json.Nodes.JsonArray; + return JsonNode.Parse(argsJson) as JsonArray; } #endregion @@ -779,14 +343,11 @@ private InvokeTestHarness(DevFlowAgentService service, AgentClient client) } public static async Task CreateAsync() - => await CreateAsync(Array.Empty()); + => await CreateWithDispatcherAsync(new ImmediateDispatcher()); - public static async Task CreateAsync(params View[] views) - => await CreateWithDispatcherAsync(new ImmediateDispatcher(), views); - - public static async Task CreateWithDispatcherAsync(IDispatcher dispatcher, params View[] views) + public static async Task CreateWithDispatcherAsync(IDispatcher dispatcher) { - var app = new TestApplication(views); + var app = new TestApplication(); var service = new DevFlowAgentService(new AgentOptions { Port = GetFreePort() }); var client = new AgentClient("localhost", service.Port); @@ -860,12 +421,8 @@ public event EventHandler? Tick { add { } remove { } } public void Stop() => IsRunning = false; } - private sealed class TestApplication : Application, IVisualTreeElement + private sealed class TestApplication : Application { - private readonly IReadOnlyList _children; - public TestApplication(IEnumerable views) => _children = views.Cast().ToArray(); - IReadOnlyList IVisualTreeElement.GetVisualChildren() => _children; - IVisualTreeElement? IVisualTreeElement.GetVisualParent() => null; } #endregion @@ -873,10 +430,6 @@ private sealed class TestApplication : Application, IVisualTreeElement #region Test Fixture Classes -/// -/// Test helper class with [DevFlowAction]-annotated methods for invoke tests. -/// These methods are discovered via assembly scanning during tests. -/// public static class TestInvokeHelpers { [ThreadStatic] @@ -893,38 +446,55 @@ public static void ResetDispatchState() [DevFlowAction("test-greet", Description = "Returns a greeting for the given name")] public static string Greet( - [Description("The name to greet")] string name = "Friend") + [System.ComponentModel.Description("The name to greet")] string name = "Friend") => $"Hello, {name}!"; [DevFlowAction("test-add", Description = "Adds two numbers")] public static int Add( - [Description("First number")] int a, - [Description("Second number")] int b) + [System.ComponentModel.Description("First number")] int a, + [System.ComponentModel.Description("Second number")] int b) => a + b; [DevFlowAction("test-dispatch-state", Description = "Returns whether invocation is dispatched")] public static string GetActionDispatchState() - => GetDispatchState(); - - public static string GetDispatchState() => IsDispatched ? "dispatched" : "not-dispatched"; - public static Task GetValueAsync(string key) + [DevFlowAction("test-async", Description = "Returns an async value")] + public static Task GetValueAsync( + [System.ComponentModel.Description("Value key")] string key) => Task.FromResult($"async:{key}"); - public static void DoSideEffect(string value) + [DevFlowAction("test-value-task", Description = "Returns a ValueTask value")] + public static async ValueTask GetValueTaskAsync( + [System.ComponentModel.Description("Value key")] string key) + { + await Task.Yield(); + return $"value-task:{key}"; + } + + [DevFlowAction("test-side-effect", Description = "Records a side effect")] + public static void DoSideEffect( + [System.ComponentModel.Description("Value to record")] string value) => LastSideEffect = value; - public static string IsEnabled(bool enabled) + [DevFlowAction("test-bool", Description = "Formats a boolean value")] + public static string IsEnabled( + [System.ComponentModel.Description("Whether the feature is enabled")] bool enabled) => enabled.ToString(); - public static string JoinNumbers(int[] numbers) + [DevFlowAction("test-join-numbers", Description = "Joins numbers")] + public static string JoinNumbers( + [System.ComponentModel.Description("Numbers to join")] int[] numbers) => string.Join(",", numbers); - public static string GetPriority(Priority p) + [DevFlowAction("test-priority", Description = "Formats priority")] + public static string GetPriority( + [System.ComponentModel.Description("Priority value")] Priority p) => p.ToString(); - public static string FormatNullable(int? value) + [DevFlowAction("test-nullable", Description = "Formats a nullable integer")] + public static string FormatNullable( + [System.ComponentModel.Description("Nullable value")] int? value) => value.HasValue ? value.Value.ToString() : "null"; } @@ -935,56 +505,4 @@ public enum Priority High } -/// -/// Test helper class with overloaded methods for overload resolution tests. -/// -public static class TestInvokeHelpersWithOverloads -{ - public static string Concat(string a, string b) - => $"{a} {b}"; - - public static string Concat(string a, string b, string c) - => $"{a}-{b}-{c}"; - - public static string Choose(string value) - => $"string:{value}"; - - public static string Choose(int value) - => $"int:{value}"; - - public static string AmbiguousNumber(int value) - => $"int:{value}"; - - public static string AmbiguousNumber(long value) - => $"long:{value}"; -} - -/// -/// Non-static test class with instance methods for DI service resolution tests. -/// -public class TestServiceClass -{ - public string GetValue() => "service-value"; -} - -/// -/// Test View subclass with public instance methods for element invoke tests. -/// Added as a child of TestApplication so the tree walker can find it by AutomationId. -/// -public class TestInvokeView : View -{ - public string TestMethod(string input) => $"result:{input}"; - public int AddNumbers(int a, int b) => a + b; - public Task GetValueAsync(string key) => Task.FromResult($"async:{key}"); -} - -/// -/// Test service class with public instance methods for DI service resolution tests. -/// Used to verify the "resolve: service" error path when no DI container is available. -/// -public class TestService -{ - public string GetGreeting(string name) => $"Hello from service, {name}!"; -} - #endregion From 9f61813fbfb1c65c43dbbb23ee14e21ca0d6a15b Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 28 Apr 2026 15:30:30 -0400 Subject: [PATCH 24/24] Fix DevFlow action CI failures Prefer app/default-context DevFlow actions when duplicate action names are discovered so stale or secondary assembly loads do not shadow the running app action. Pin Roslyn CSharp.Workspaces with the repo Roslyn version to avoid NU1701 restore warnings from analyzer test dependencies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 + .../DevFlowAgentService.Invoke.cs | 31 ++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 00f38edf7..07d3cc8bd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,7 @@ + diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs index 9cad1cceb..d53bdae37 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.Core/DevFlowAgentService.Invoke.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Reflection; using System.Reflection.Metadata; +using System.Runtime.Loader; using System.Text.Json; [assembly: MetadataUpdateHandler(typeof(Microsoft.Maui.DevFlow.Agent.Core.DevFlowActionHotReloadHandler))] @@ -83,27 +84,49 @@ private static InvokeActionEntry[] ScanActions() } } - // Detect and deduplicate shadowed action names (keep first occurrence) + // Detect and deduplicate shadowed action names (keep the preferred occurrence) var duplicates = actions .GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase) .Where(g => g.Count() > 1); foreach (var group in duplicates) { - var shadowed = group.Skip(1); + var preferred = SelectPreferredAction(group); + var shadowed = group.Where(a => !ReferenceEquals(a, preferred)); foreach (var dup in shadowed) { System.Diagnostics.Debug.WriteLine( - $"[Microsoft.Maui.DevFlow] Warning: Duplicate DevFlowAction name '{group.Key}' on {dup.DeclaringType}.{dup.Method.Name} shadows the first registration on {group.First().DeclaringType}.{group.First().Method.Name}. The duplicate will be ignored."); + $"[Microsoft.Maui.DevFlow] Warning: Duplicate DevFlowAction name '{group.Key}' on {dup.DeclaringType}.{dup.Method.Name} shadows the preferred registration on {preferred.DeclaringType}.{preferred.Method.Name}. The duplicate will be ignored."); } } return actions .GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase) - .Select(g => g.First()) + .Select(SelectPreferredAction) .ToArray(); } + private static InvokeActionEntry SelectPreferredAction(IEnumerable actions) + => actions + .OrderByDescending(a => AssemblyLoadContext.GetLoadContext(a.Method.DeclaringType!.Assembly) == AssemblyLoadContext.Default) + .ThenByDescending(a => IsAssemblyInAppBaseDirectory(a.Method.DeclaringType!.Assembly)) + .ThenBy(a => a.Method.DeclaringType!.Assembly.FullName, StringComparer.Ordinal) + .ThenBy(a => a.DeclaringType, StringComparer.Ordinal) + .ThenBy(a => a.Method.MetadataToken) + .First(); + + private static bool IsAssemblyInAppBaseDirectory(Assembly assembly) + { + var location = assembly.Location; + if (string.IsNullOrEmpty(location)) + return false; + + return string.Equals( + Path.GetFullPath(Path.GetDirectoryName(location) ?? string.Empty).TrimEnd(Path.DirectorySeparatorChar), + Path.GetFullPath(AppContext.BaseDirectory).TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + } + private static InvokeParameterInfo[] BuildParameterInfoList(MethodInfo method) { return method.GetParameters().Select(p => new InvokeParameterInfo