From 246f4bfc3509b14d08cbb039291a72338d15c9d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:32:01 +0000 Subject: [PATCH 1/3] Initial plan From 8550bc58e617109ba403b905e1846e2932a6d177 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:41:33 +0000 Subject: [PATCH 2/3] Add window-relative click/drag pixel endpoints with window targets --- docs/agent_control.md | 4 +-- src/Agent/AgentInstructions.md | 15 ++++---- src/Agent/AgentNativeMethods.cs | 4 +++ src/Agent/ToolCatalog.cs | 10 +++--- src/Commands/ClickCommand.cs | 31 ++++++++++++----- src/Commands/DragCommand.cs | 34 ++++++++++++++----- src/Commands/WindowTargetingAgentCommand.cs | 26 ++++++++++++++ .../Commands/UiaFailureProbeCommand.cs | 3 ++ .../WindowTargetingAgentCommandTests.cs | 14 ++++++++ .../Services/AgentServerTests.cs | 3 ++ 10 files changed, 114 insertions(+), 30 deletions(-) diff --git a/docs/agent_control.md b/docs/agent_control.md index 5c48552..962c8e3 100644 --- a/docs/agent_control.md +++ b/docs/agent_control.md @@ -199,9 +199,9 @@ case-insensitive), `handle` (HWND), `process` (process name without `.exe`), | `find` | Find a **UI Automation element** by name / automation id / class. | window target, `by` (`name`\|`automationid`\|`classname`), `value`, `timeout` | | `wait-for` | Same as `find`, but waits up to a timeout for the element to appear (default 5 s). | window target, `by`, `value`, `timeout` | | `invoke` | Drive a UI Automation element pattern (incl. select for SelectionItem); far more reliable than coordinate clicks. | window target, `by`, `value`, `action` (`invoke`\|`toggle`\|`setvalue`\|`setfocus`\|`expand`\|`collapse`\|`select`), `text` | -| `drag` | Press → move along a path → release, dispatched **atomically** (nothing interleaves). Each endpoint is a UI Automation element (dragged from/to its centre) or an absolute screen pixel; add `path` waypoints for a curved/multi-stop drag. Covers window resize/move by chrome, sliders, marquee-select, drag-reorder. | window target (needed when an endpoint is an element); `from`/`to` each `{ by, value }` or `{ x, y }`; optional `path` `[{ x, y }, …]` | +| `drag` | Press → move along a path → release, dispatched **atomically** (nothing interleaves). Each endpoint is a UI Automation element (dragged from/to its centre) or a pixel endpoint: with a window target, endpoint `{ x, y }` is window-client-relative; without a window target, `{ x, y }` is absolute screen pixels. `path` waypoints remain absolute screen pixels. Covers window resize/move by chrome, sliders, marquee-select, drag-reorder. | window target (required for element endpoints; optional for window-relative pixel endpoints); `from`/`to` each `{ by, value }` or `{ x, y }`; optional `path` `[{ x, y }, …]` | | `launch` | Launch an app directly (path + args + working dir); gated. Returns pid and primary window handle/info when the window appears. Preferred over Win+R composition. | `path` (required), `arguments`, `workingDirectory`, `timeout` | -| `click` | Click at a point (a UI Automation element's centre or an absolute screen pixel); move+click is dispatched **atomically**. For element types `invoke` can't drive, or when you must target a pixel. Prefer `invoke` for ordinary buttons/menus. | window target (needed when `at` is an element); `at` = `{ by, value }` or `{ x, y }`; `button` (`left`\|`right`\|`middle`, default `left`); `count` (`1`\|`2`, default `1`) | +| `click` | Click at a point (a UI Automation element's centre or a pixel endpoint); move+click is dispatched **atomically**. With a window target, `at: { x, y }` is window-client-relative (target client origin + offset). Without a window target, `{ x, y }` is absolute screen pixels. For element types `invoke` can't drive, or when you must target a pixel. Prefer `invoke` for ordinary buttons/menus. | window target (required for element endpoints; optional for window-relative pixel endpoints); `at` = `{ by, value }` or `{ x, y }`; `button` (`left`\|`right`\|`middle`, default `left`); `count` (`1`\|`2`, default `1`) | | `focus` | Give a window (and optionally a control in it) real **keyboard focus** so `send_command`/`chars` keystrokes reach it. Foregrounds the window, clicks the control (a real click focuses custom-drawn surfaces a bare `setfocus` misses; e.g. a MAUI GraphicsView), then **verifies**. Fails `foreground` if the window won't activate, `focus` if no control takes focus. Use before firing an app's own keyboard shortcut at a specific surface. | window target; optional `at` = `{ by, value }` or `{ x, y }` (omit to just foreground + confirm focus) | | `record` | Record a window or region to an **animated GIF** over time (start/stop or a bounded one-shot). | window target, or region `x`/`y`/`width`/`height`; `action` (`start`\|`stop`\|`oneshot`), `fps`, `durationMs`, `maxWidth`, `file` | diff --git a/src/Agent/AgentInstructions.md b/src/Agent/AgentInstructions.md index 4b9a622..ec63eff 100644 --- a/src/Agent/AgentInstructions.md +++ b/src/Agent/AgentInstructions.md @@ -27,7 +27,8 @@ popups are not enumerated by title/process; target them by handle or `foreground so you can pick a control instead of guessing pixels; `capture` returns a PNG (works on composited WinUI/WPF surfaces; check `bytes` — full windows are costly and inline base64 can blow your token budget). Browser chrome is UIA-targetable; in-page web content is not — verify with region `capture` and -screen-absolute pixel `click` (window origin from `query` bounds + offset; re-capture after layout shifts). +pixel `click` (with a window target, `{x,y}` is window-client-relative; without one, screen-absolute; +re-capture after layout shifts). Use `capture` for a single state check when the tree can't answer; use `record` ONLY to show CHANGE over time; a bounded one-shot (`durationMs`) or `action:start` then `action:stop`; keep recordings short (fps/duration are capped and frames downscaled), and remember it captures whatever is on screen for the @@ -46,7 +47,8 @@ raise `maxNodes` or target a deeper window so you don't reason over a partial tr non-fatal; errorCategory tells you how to recover. The bounds `query`/`find` report are ABSOLUTE screen pixels; the `displays` tool reports every monitor's pixel bounds and DPI/scale (and the union virtualBounds) so you can interpret those bounds across multiple/scaled monitors and place pixel clicks or -drags without measuring the screen yourself. +drags without measuring the screen yourself. Window-relative click/drag endpoints are interpreted in that +same per-monitor pixel space after adding the target window's client origin. 3. ACT: prefer `invoke` (by name/automationId/classname; action invoke|toggle|setvalue|setfocus|expand| collapse|select) over coordinate clicks; it is far more reliable. To put text in a native field, prefer @@ -65,16 +67,17 @@ that action; re-finding it will never help; pick a different action or `click` i expand|collapse|select). To DRAG (resize a window by its sizing border, move one by its title bar, drag a slider/handle, marquee-select, or reorder; there is no `invoke` for these), use the `drag` tool: give a `from` and a `to`, each either an element `{ by, value }` -in the target window (dragged from/to its centre) or an absolute screen pixel `{ x, y }`, plus optional +in the target window (dragged from/to its centre) or a pixel `{ x, y }` (window-client-relative when a +window target is set, otherwise screen-absolute), plus optional `path` waypoints for a curved or multi-stop drag. The whole press→move→release is dispatched ATOMICALLY, so prefer it over hand-rolling `mouse:lbd`/`mouse:mt`/`mouse:lbu` (which can interleave with other commands). -Coords are absolute screen pixels (the same space `query`/`find` bounds report), so you can drag straight +Without a window target, coords are absolute screen pixels (the same space `query`/`find` bounds report), so you can drag straight from one control's bounds to another's. Re-`query` afterward: a moved/resized window's controls are at new bounds. For window-level move/resize, use the `window` tool: `action:"move"`/`"resize"` with target coordinates and optional `animate:true` to make the window appear to be dragged rather than instantly teleported. `window` also handles `minimize`, `maximize`, `restore`, and `foreground`. -To CLICK a point `invoke` can't reach (a custom-drawn cell, a canvas/map coordinate, or a barepixel), use the `click` tool: give `at` as an element `{ by, value }` (clicked at its centre) or an -absolute screen pixel `{ x, y }`, with optional `button` (left|right|middle) and `count` +To CLICK a point `invoke` can't reach (a custom-drawn cell, a canvas/map coordinate, or a barepixel), use the `click` tool: give `at` as an element `{ by, value }` (clicked at its centre) or a +pixel `{ x, y }` (window-client-relative when a window target is set, otherwise absolute screen pixels), with optional `button` (left|right|middle) and `count` (2 = double-click); the move+click is dispatched atomically. Still prefer `invoke` for ordinary buttons and menu items; it doesn't depend on the control being on-screen and unobscured. System file dialogs (Open, Save Print Output As) are separate windows; `wait-for`/`query` by title, reuse the returned `handle`, and act on that diff --git a/src/Agent/AgentNativeMethods.cs b/src/Agent/AgentNativeMethods.cs index 648a6d1..64c32a2 100644 --- a/src/Agent/AgentNativeMethods.cs +++ b/src/Agent/AgentNativeMethods.cs @@ -86,6 +86,10 @@ internal static class AgentNativeMethods { [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetClientRect(IntPtr hwnd, out NativeRect lpRect); + [DllImport(User32, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool ClientToScreen(IntPtr hwnd, ref Point lpPoint); + [DllImport(User32, SetLastError = true, CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hwnd, StringBuilder lpString, int nMaxCount); diff --git a/src/Agent/ToolCatalog.cs b/src/Agent/ToolCatalog.cs index 8e48741..f7ea835 100644 --- a/src/Agent/ToolCatalog.cs +++ b/src/Agent/ToolCatalog.cs @@ -343,8 +343,8 @@ private static JsonObject BuildInvokeSchema() { private static JsonObject BuildDragSchema() { JsonObject dragProps = WindowTargetProps(); - dragProps["from"] = EndpointSchema("Drag start: an element ({ by, value }) in the target window, or a pixel ({ x, y })."); - dragProps["to"] = EndpointSchema("Drag end: an element ({ by, value }) in the target window, or a pixel ({ x, y })."); + dragProps["from"] = EndpointSchema("Drag start: an element ({ by, value }) in the target window, or a pixel ({ x, y }). With a window target and a pixel endpoint, x/y are window-client-relative; without a window target they are absolute screen pixels."); + dragProps["to"] = EndpointSchema("Drag end: an element ({ by, value }) in the target window, or a pixel ({ x, y }). With a window target and a pixel endpoint, x/y are window-client-relative; without a window target they are absolute screen pixels."); dragProps["path"] = new JsonObject { ["type"] = "array", ["description"] = "Optional intermediate waypoints (absolute screen pixels) between from and to.", @@ -357,17 +357,17 @@ private static JsonObject BuildDragSchema() { }, }; return Tool("drag", - "Press → move along a path → release, dispatched atomically (no interleaving). Endpoints are an element (by/value, dragged from/to its centre) or an absolute screen pixel; add path waypoints for a curved/multi-stop drag. Covers window resize/move by chrome, sliders, marquee select, drag-reorder. Give a window target when either endpoint is an element.", + "Press → move along a path → release, dispatched atomically (no interleaving). Endpoints are an element (by/value, dragged from/to its centre) or a pixel: with a window target, pixel endpoints are window-client-relative; without one, they are absolute screen pixels. Add path waypoints (always absolute pixels) for a curved/multi-stop drag. Covers window resize/move by chrome, sliders, marquee select, drag-reorder. Give a window target when either endpoint is an element.", dragProps, ["from", "to"]); } private static JsonObject BuildClickSchema() { JsonObject clickProps = WindowTargetProps(); - clickProps["at"] = EndpointSchema("Where to click: an element ({ by, value }) in the target window (its centre) or an absolute screen pixel ({ x, y })."); + clickProps["at"] = EndpointSchema("Where to click: an element ({ by, value }) in the target window (its centre) or a pixel ({ x, y }). With a window target and a pixel endpoint, x/y are window-client-relative; without a window target they are absolute screen pixels."); clickProps["button"] = PropSchema("string", "Button: left | right | middle (default left)"); clickProps["count"] = PropSchema("integer", "Click count: 1 = single, 2 = double (default 1)"); return Tool("click", - "Click at a point; an element (by/value, clicked at its centre) or an absolute screen pixel (the space query/find bounds report). Move+click is dispatched atomically. Prefer invoke for buttons/menus; use click for element types invoke cannot drive or when you must target a pixel. Give a window target when 'at' is an element.", + "Click at a point; an element (by/value, clicked at its centre) or a pixel endpoint. With a window target, pixel endpoints are window-client-relative (target client origin + offset); without one they are absolute screen pixels (the space query/find bounds report). Move+click is dispatched atomically. Prefer invoke for buttons/menus; use click for element types invoke cannot drive or when you must target a pixel. Give a window target when 'at' is an element or when you want window-relative pixels.", clickProps, ["at"]); } diff --git a/src/Commands/ClickCommand.cs b/src/Commands/ClickCommand.cs index 2f57191..cc521dc 100644 --- a/src/Commands/ClickCommand.cs +++ b/src/Commands/ClickCommand.cs @@ -11,9 +11,10 @@ namespace MCEControl; /// /// Agent actuation command: a single mouse click at a target point (issue #122). The point is either an /// absolute screen pixel (x/y) or a UI Automation element in the target window (value, -/// resolved to the centre of its bounds); the same pixel space query/find report, so an -/// agent can click a control it just observed without converting to normalized coordinates itself. The -/// move-then-click is dispatched atomically by so it cannot +/// resolved to the centre of its bounds). When a window target is supplied and the endpoint is pixel +/// coordinates, x/y are interpreted as window-client-relative and translated to absolute +/// screen pixels via the target window's client origin. The move-then-click is dispatched atomically by +/// so it cannot /// interleave with another command's mouse input (the hazard #113 warns about when a caller hand-rolls /// mt/lbc). Gated by and audited /// (structurally, via ). Disabled by default (security). @@ -41,8 +42,9 @@ public static List BuiltInCommands { protected override string AuditDetails() => $"click at (by={By} value='{Value}' {X},{Y}) button={Button} count={Count} window handle={Handle} title='{Window}' process='{Process}'"; - // A window is only needed to resolve an element endpoint; a pure pixel click needs none. - protected override bool RequiresWindowTarget => !string.IsNullOrEmpty(Value); + // A window is needed for element endpoints, and also for pixel endpoints when a target selector was + // provided (pixels become window-client-relative in that case). + protected override bool RequiresWindowTarget => HasWindowTarget || !string.IsNullOrEmpty(Value); protected override CommandResult ExecuteCore(WindowInfo? target) { IntPtr hwnd = target is null ? IntPtr.Zero : new IntPtr(target.Handle); @@ -70,13 +72,26 @@ protected override CommandResult ExecuteCore(WindowInfo? target) { /// /// Resolves the click point to an absolute screen pixel: the centre of an element (/ /// ) when is set, else the literal (, - /// ). Returns false with a structured when the element - /// can't be resolved (not found, ambiguous, stale window, elevated target; #261). + /// ) when no window target is set, else the target window's client-origin offset plus + /// (, ) when a window target is set. Returns false with a structured + /// when the element can't be resolved (not found, ambiguous, stale window, + /// elevated target; #261), or when a targeted window disappears before its client origin can be read. /// private bool TryResolvePoint(IntPtr hwnd, out (int X, int Y) point, out CommandResult? failure) { failure = null; if (string.IsNullOrEmpty(Value)) { - point = (X, Y); + if (hwnd == IntPtr.Zero) { + point = (X, Y); + return true; + } + if (!TryGetWindowClientOrigin(hwnd, out (int X, int Y) origin)) { + point = default; + failure = CommandResult.Fail(Cmd, + "The target window disappeared before click coordinates could be resolved. Re-query and retry.", + "window-closed", "stale-element"); + return false; + } + point = OffsetByClientOrigin((X, Y), origin); return true; } string by = string.IsNullOrEmpty(By) ? "name" : By; diff --git a/src/Commands/DragCommand.cs b/src/Commands/DragCommand.cs index 2e6886e..7559817 100644 --- a/src/Commands/DragCommand.cs +++ b/src/Commands/DragCommand.cs @@ -12,7 +12,9 @@ namespace MCEControl; /// Agent actuation command: a first-class press → move-path → release drag (issue #123). Each endpoint /// is either an absolute screen pixel (fromX/fromY, toX/toY) or a UI Automation element in /// the target window (fromValue/toValue, resolved to its bounds' centre), with optional -/// intermediate waypoints (). The whole gesture is dispatched atomically by +/// intermediate waypoints (). When a window target is supplied and an endpoint is +/// pixel coordinates, that endpoint is interpreted as window-client-relative and translated to absolute +/// screen pixels via the target window's client origin. The whole gesture is dispatched atomically by /// so it cannot interleave with another command's mouse input /// (the hazard #113 warns about when a caller hand-rolls lbd/mt/lbu). Gated by /// and audited (structurally, via @@ -46,8 +48,9 @@ public static List BuiltInCommands { protected override string AuditDetails() => $"drag from (by={FromBy} value='{FromValue}' {FromX},{FromY}) to (by={ToBy} value='{ToValue}' {ToX},{ToY}) window handle={Handle} title='{Window}' process='{Process}'"; - // A window is only needed to resolve element endpoints; a pure coordinate drag needs none. - protected override bool RequiresWindowTarget => !string.IsNullOrEmpty(FromValue) || !string.IsNullOrEmpty(ToValue); + // A window is needed for element endpoints, and also for pixel endpoints when a target selector was + // provided (pixel endpoints become window-client-relative in that case). + protected override bool RequiresWindowTarget => HasWindowTarget || !string.IsNullOrEmpty(FromValue) || !string.IsNullOrEmpty(ToValue); protected override CommandResult ExecuteCore(WindowInfo? target) { IntPtr hwnd = target is null ? IntPtr.Zero : new IntPtr(target.Handle); @@ -79,16 +82,29 @@ protected override CommandResult ExecuteCore(WindowInfo? target) { /// /// Resolves one drag endpoint to an absolute screen pixel: an element (by/value, centre of its /// bounds) when is set, else the literal (, - /// ). Returns false with a structured when an - /// element endpoint has no window or can't be resolved (not found, ambiguous, stale window, - /// elevated target; #261). ("Drag start"/"Drag end") is prefixed onto - /// the detail of EVERY failure category (via ) so a two-endpoint drag - /// always says which end failed (#262 review). + /// ) when no window target is set, else the target window's client-origin + /// offset plus (, ) when a window target is set. Returns + /// false with a structured when an element endpoint has no window or + /// can't be resolved (not found, ambiguous, stale window, elevated target; #261), or when a targeted + /// window disappears before its client origin can be read. ("Drag + /// start"/"Drag end") is prefixed onto the detail of EVERY failure category (via + /// ) so a two-endpoint drag always says which end failed (#262 review). /// private static bool TryResolvePoint(IntPtr hwnd, string by, string value, int x, int y, string endpoint, string cmd, out (int X, int Y) point, out CommandResult? failure) { failure = null; if (string.IsNullOrEmpty(value)) { - point = (x, y); + if (hwnd == IntPtr.Zero) { + point = (x, y); + return true; + } + if (!TryGetWindowClientOrigin(hwnd, out (int X, int Y) origin)) { + point = default; + failure = LabelEndpoint(endpoint, CommandResult.Fail(cmd, + "The target window disappeared before drag coordinates could be resolved. Re-query and retry.", + "window-closed", "stale-element")); + return false; + } + point = OffsetByClientOrigin((x, y), origin); return true; } if (hwnd == IntPtr.Zero) { diff --git a/src/Commands/WindowTargetingAgentCommand.cs b/src/Commands/WindowTargetingAgentCommand.cs index 3da5529..97c72de 100644 --- a/src/Commands/WindowTargetingAgentCommand.cs +++ b/src/Commands/WindowTargetingAgentCommand.cs @@ -1,6 +1,8 @@ // Copyright © Kindel, LLC - http://www.kindel.com // Published under the MIT License - Source on GitHub: https://github.com/tig/mcec +using System; +using System.Drawing; using System.Xml.Serialization; namespace MCEControl; @@ -115,6 +117,30 @@ protected sealed override CommandResult ExecuteCore() { _ => null, }; + /// + /// Resolves a window's client-area origin in absolute screen pixels. Uses + /// (0,0) so coordinate endpoints inferred from a + /// window capture can be replayed without hand-adding monitor offsets. + /// + protected static bool TryGetWindowClientOrigin(IntPtr hwnd, out (int X, int Y) origin) { + origin = default; + if (hwnd == IntPtr.Zero) { + return false; + } + + Point clientOrigin = new(0, 0); + if (!AgentNativeMethods.ClientToScreen(hwnd, ref clientOrigin)) { + return false; + } + + origin = (clientOrigin.X, clientOrigin.Y); + return true; + } + + /// Adds a client-area origin to a window-relative point to get an absolute screen pixel. + protected static (int X, int Y) OffsetByClientOrigin((int X, int Y) point, (int X, int Y) clientOrigin) => + (clientOrigin.X + point.X, clientOrigin.Y + point.Y); + // No Clone override: the shared selectors are value/string-typed, so the base // MemberwiseClone-based Command.Clone (#207) copies them by construction. } diff --git a/tests/MCEControl.xUnit/Commands/UiaFailureProbeCommand.cs b/tests/MCEControl.xUnit/Commands/UiaFailureProbeCommand.cs index 31a57f4..efe2755 100644 --- a/tests/MCEControl.xUnit/Commands/UiaFailureProbeCommand.cs +++ b/tests/MCEControl.xUnit/Commands/UiaFailureProbeCommand.cs @@ -17,4 +17,7 @@ public sealed class UiaFailureProbeCommand : WindowTargetingAgentCommand { UiaFindFailureFor(cmd, by, value, outcome); public static CommandResult? Failure(string cmd, UiaFailureKind kind) => UiaFailureFor(cmd, kind); + + public static (int X, int Y) OffsetPoint((int X, int Y) point, (int X, int Y) origin) => + OffsetByClientOrigin(point, origin); } diff --git a/tests/MCEControl.xUnit/Commands/WindowTargetingAgentCommandTests.cs b/tests/MCEControl.xUnit/Commands/WindowTargetingAgentCommandTests.cs index cf0da4b..42e80c6 100644 --- a/tests/MCEControl.xUnit/Commands/WindowTargetingAgentCommandTests.cs +++ b/tests/MCEControl.xUnit/Commands/WindowTargetingAgentCommandTests.cs @@ -53,4 +53,18 @@ public void UiaFindFailureFor_FoundOrCleanMiss_IsNoFailure() { Assert.Null(UiaFailureProbeCommand.FindFailure("find", "name", "OK", found)); Assert.Null(UiaFailureProbeCommand.FindFailure("find", "name", "OK", UiaFindOutcome.NotFound)); } + + [Fact] + public void OffsetByClientOrigin_AddsWindowClientOriginToWindowRelativePoint() { + (int X, int Y) point = UiaFailureProbeCommand.OffsetPoint((412, 88), (1923, 45)); + + Assert.Equal((2335, 133), point); + } + + [Fact] + public void OffsetByClientOrigin_PreservesNegativeOffsets() { + (int X, int Y) point = UiaFailureProbeCommand.OffsetPoint((-8, 0), (3200, 120)); + + Assert.Equal((3192, 120), point); + } } diff --git a/tests/MCEControl.xUnit/Services/AgentServerTests.cs b/tests/MCEControl.xUnit/Services/AgentServerTests.cs index e10c266..4553d1d 100644 --- a/tests/MCEControl.xUnit/Services/AgentServerTests.cs +++ b/tests/MCEControl.xUnit/Services/AgentServerTests.cs @@ -159,6 +159,7 @@ public void Dispatch_ToolsList_ClickTool_DeclaresAtEndpoint() { Assert.True(props.ContainsKey("at")); Assert.True(props.ContainsKey("button")); Assert.True(props.ContainsKey("count")); + Assert.Contains("window-client-relative", props["at"]!["description"]!.GetValue(), StringComparison.Ordinal); List required = []; foreach (JsonNode? r in schema["required"]!.AsArray()) { @@ -316,6 +317,8 @@ public void Dispatch_ToolsList_DragTool_DeclaresFromToEndpoints() { Assert.True(props.ContainsKey("from")); Assert.True(props.ContainsKey("to")); Assert.True(props.ContainsKey("path")); + Assert.Contains("window-client-relative", props["from"]!["description"]!.GetValue(), StringComparison.Ordinal); + Assert.Contains("window-client-relative", props["to"]!["description"]!.GetValue(), StringComparison.Ordinal); List required = []; foreach (JsonNode? r in schema["required"]!.AsArray()) { From 0767e44d6d69716d2436ac5abbd09a533f42aea5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:35:54 +0000 Subject: [PATCH 3/3] Align click/drag window-relative pixels with capture coordinates --- docs/agent_control.md | 4 ++-- src/Agent/AgentInstructions.md | 8 ++++---- src/Agent/AgentNativeMethods.cs | 4 ---- src/Agent/ToolCatalog.cs | 10 +++++----- src/Commands/ClickCommand.cs | 14 ++++++------- src/Commands/DragCommand.cs | 14 ++++++------- src/Commands/WindowTargetingAgentCommand.cs | 20 +++++++++---------- .../Commands/UiaFailureProbeCommand.cs | 2 +- .../WindowTargetingAgentCommandTests.cs | 4 ++-- .../Services/AgentServerTests.cs | 6 +++--- 10 files changed, 40 insertions(+), 46 deletions(-) diff --git a/docs/agent_control.md b/docs/agent_control.md index 962c8e3..c0384df 100644 --- a/docs/agent_control.md +++ b/docs/agent_control.md @@ -199,9 +199,9 @@ case-insensitive), `handle` (HWND), `process` (process name without `.exe`), | `find` | Find a **UI Automation element** by name / automation id / class. | window target, `by` (`name`\|`automationid`\|`classname`), `value`, `timeout` | | `wait-for` | Same as `find`, but waits up to a timeout for the element to appear (default 5 s). | window target, `by`, `value`, `timeout` | | `invoke` | Drive a UI Automation element pattern (incl. select for SelectionItem); far more reliable than coordinate clicks. | window target, `by`, `value`, `action` (`invoke`\|`toggle`\|`setvalue`\|`setfocus`\|`expand`\|`collapse`\|`select`), `text` | -| `drag` | Press → move along a path → release, dispatched **atomically** (nothing interleaves). Each endpoint is a UI Automation element (dragged from/to its centre) or a pixel endpoint: with a window target, endpoint `{ x, y }` is window-client-relative; without a window target, `{ x, y }` is absolute screen pixels. `path` waypoints remain absolute screen pixels. Covers window resize/move by chrome, sliders, marquee-select, drag-reorder. | window target (required for element endpoints; optional for window-relative pixel endpoints); `from`/`to` each `{ by, value }` or `{ x, y }`; optional `path` `[{ x, y }, …]` | +| `drag` | Press → move along a path → release, dispatched **atomically** (nothing interleaves). Each endpoint is a UI Automation element (dragged from/to its centre) or a pixel endpoint: with a window target, endpoint `{ x, y }` is window-relative (window top-left + offset, matching `capture` coordinates); without a window target, `{ x, y }` is absolute screen pixels. `path` waypoints remain absolute screen pixels. Covers window resize/move by chrome, sliders, marquee-select, drag-reorder. | window target (required for element endpoints; optional for window-relative pixel endpoints); `from`/`to` each `{ by, value }` or `{ x, y }`; optional `path` `[{ x, y }, …]` | | `launch` | Launch an app directly (path + args + working dir); gated. Returns pid and primary window handle/info when the window appears. Preferred over Win+R composition. | `path` (required), `arguments`, `workingDirectory`, `timeout` | -| `click` | Click at a point (a UI Automation element's centre or a pixel endpoint); move+click is dispatched **atomically**. With a window target, `at: { x, y }` is window-client-relative (target client origin + offset). Without a window target, `{ x, y }` is absolute screen pixels. For element types `invoke` can't drive, or when you must target a pixel. Prefer `invoke` for ordinary buttons/menus. | window target (required for element endpoints; optional for window-relative pixel endpoints); `at` = `{ by, value }` or `{ x, y }`; `button` (`left`\|`right`\|`middle`, default `left`); `count` (`1`\|`2`, default `1`) | +| `click` | Click at a point (a UI Automation element's centre or a pixel endpoint); move+click is dispatched **atomically**. With a window target, `at: { x, y }` is window-relative (window top-left + offset, matching `capture` coordinates). Without a window target, `{ x, y }` is absolute screen pixels. For element types `invoke` can't drive, or when you must target a pixel. Prefer `invoke` for ordinary buttons/menus. | window target (required for element endpoints; optional for window-relative pixel endpoints); `at` = `{ by, value }` or `{ x, y }`; `button` (`left`\|`right`\|`middle`, default `left`); `count` (`1`\|`2`, default `1`) | | `focus` | Give a window (and optionally a control in it) real **keyboard focus** so `send_command`/`chars` keystrokes reach it. Foregrounds the window, clicks the control (a real click focuses custom-drawn surfaces a bare `setfocus` misses; e.g. a MAUI GraphicsView), then **verifies**. Fails `foreground` if the window won't activate, `focus` if no control takes focus. Use before firing an app's own keyboard shortcut at a specific surface. | window target; optional `at` = `{ by, value }` or `{ x, y }` (omit to just foreground + confirm focus) | | `record` | Record a window or region to an **animated GIF** over time (start/stop or a bounded one-shot). | window target, or region `x`/`y`/`width`/`height`; `action` (`start`\|`stop`\|`oneshot`), `fps`, `durationMs`, `maxWidth`, `file` | diff --git a/src/Agent/AgentInstructions.md b/src/Agent/AgentInstructions.md index ec63eff..da15016 100644 --- a/src/Agent/AgentInstructions.md +++ b/src/Agent/AgentInstructions.md @@ -27,7 +27,7 @@ popups are not enumerated by title/process; target them by handle or `foreground so you can pick a control instead of guessing pixels; `capture` returns a PNG (works on composited WinUI/WPF surfaces; check `bytes` — full windows are costly and inline base64 can blow your token budget). Browser chrome is UIA-targetable; in-page web content is not — verify with region `capture` and -pixel `click` (with a window target, `{x,y}` is window-client-relative; without one, screen-absolute; +pixel `click` (with a window target, `{x,y}` is window-relative to the captured window bounds; without one, screen-absolute; re-capture after layout shifts). Use `capture` for a single state check when the tree can't answer; use `record` ONLY to show CHANGE over time; a bounded one-shot (`durationMs`) or `action:start` then `action:stop`; keep recordings short @@ -48,7 +48,7 @@ non-fatal; errorCategory tells you how to recover. The bounds `query`/`find` rep pixels; the `displays` tool reports every monitor's pixel bounds and DPI/scale (and the union virtualBounds) so you can interpret those bounds across multiple/scaled monitors and place pixel clicks or drags without measuring the screen yourself. Window-relative click/drag endpoints are interpreted in that -same per-monitor pixel space after adding the target window's client origin. +same per-monitor pixel space after adding the target window's top-left. 3. ACT: prefer `invoke` (by name/automationId/classname; action invoke|toggle|setvalue|setfocus|expand| collapse|select) over coordinate clicks; it is far more reliable. To put text in a native field, prefer @@ -67,7 +67,7 @@ that action; re-finding it will never help; pick a different action or `click` i expand|collapse|select). To DRAG (resize a window by its sizing border, move one by its title bar, drag a slider/handle, marquee-select, or reorder; there is no `invoke` for these), use the `drag` tool: give a `from` and a `to`, each either an element `{ by, value }` -in the target window (dragged from/to its centre) or a pixel `{ x, y }` (window-client-relative when a +in the target window (dragged from/to its centre) or a pixel `{ x, y }` (window-relative when a window target is set, otherwise screen-absolute), plus optional `path` waypoints for a curved or multi-stop drag. The whole press→move→release is dispatched ATOMICALLY, so prefer it over hand-rolling `mouse:lbd`/`mouse:mt`/`mouse:lbu` (which can interleave with other commands). @@ -77,7 +77,7 @@ bounds. For window-level move/resize, use the `window` tool: `action:"move"`/`"r coordinates and optional `animate:true` to make the window appear to be dragged rather than instantly teleported. `window` also handles `minimize`, `maximize`, `restore`, and `foreground`. To CLICK a point `invoke` can't reach (a custom-drawn cell, a canvas/map coordinate, or a barepixel), use the `click` tool: give `at` as an element `{ by, value }` (clicked at its centre) or a -pixel `{ x, y }` (window-client-relative when a window target is set, otherwise absolute screen pixels), with optional `button` (left|right|middle) and `count` +pixel `{ x, y }` (window-relative when a window target is set, otherwise absolute screen pixels), with optional `button` (left|right|middle) and `count` (2 = double-click); the move+click is dispatched atomically. Still prefer `invoke` for ordinary buttons and menu items; it doesn't depend on the control being on-screen and unobscured. System file dialogs (Open, Save Print Output As) are separate windows; `wait-for`/`query` by title, reuse the returned `handle`, and act on that diff --git a/src/Agent/AgentNativeMethods.cs b/src/Agent/AgentNativeMethods.cs index 64c32a2..648a6d1 100644 --- a/src/Agent/AgentNativeMethods.cs +++ b/src/Agent/AgentNativeMethods.cs @@ -86,10 +86,6 @@ internal static class AgentNativeMethods { [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetClientRect(IntPtr hwnd, out NativeRect lpRect); - [DllImport(User32, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool ClientToScreen(IntPtr hwnd, ref Point lpPoint); - [DllImport(User32, SetLastError = true, CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hwnd, StringBuilder lpString, int nMaxCount); diff --git a/src/Agent/ToolCatalog.cs b/src/Agent/ToolCatalog.cs index f7ea835..b9593fc 100644 --- a/src/Agent/ToolCatalog.cs +++ b/src/Agent/ToolCatalog.cs @@ -343,8 +343,8 @@ private static JsonObject BuildInvokeSchema() { private static JsonObject BuildDragSchema() { JsonObject dragProps = WindowTargetProps(); - dragProps["from"] = EndpointSchema("Drag start: an element ({ by, value }) in the target window, or a pixel ({ x, y }). With a window target and a pixel endpoint, x/y are window-client-relative; without a window target they are absolute screen pixels."); - dragProps["to"] = EndpointSchema("Drag end: an element ({ by, value }) in the target window, or a pixel ({ x, y }). With a window target and a pixel endpoint, x/y are window-client-relative; without a window target they are absolute screen pixels."); + dragProps["from"] = EndpointSchema("Drag start: an element ({ by, value }) in the target window, or a pixel ({ x, y }). With a window target and a pixel endpoint, x/y are window-relative (window top-left + offset); without a window target they are absolute screen pixels."); + dragProps["to"] = EndpointSchema("Drag end: an element ({ by, value }) in the target window, or a pixel ({ x, y }). With a window target and a pixel endpoint, x/y are window-relative (window top-left + offset); without a window target they are absolute screen pixels."); dragProps["path"] = new JsonObject { ["type"] = "array", ["description"] = "Optional intermediate waypoints (absolute screen pixels) between from and to.", @@ -357,17 +357,17 @@ private static JsonObject BuildDragSchema() { }, }; return Tool("drag", - "Press → move along a path → release, dispatched atomically (no interleaving). Endpoints are an element (by/value, dragged from/to its centre) or a pixel: with a window target, pixel endpoints are window-client-relative; without one, they are absolute screen pixels. Add path waypoints (always absolute pixels) for a curved/multi-stop drag. Covers window resize/move by chrome, sliders, marquee select, drag-reorder. Give a window target when either endpoint is an element.", + "Press → move along a path → release, dispatched atomically (no interleaving). Endpoints are an element (by/value, dragged from/to its centre) or a pixel: with a window target, pixel endpoints are window-relative (matching capture coordinates); without one, they are absolute screen pixels. Add path waypoints (always absolute pixels) for a curved/multi-stop drag. Covers window resize/move by chrome, sliders, marquee select, drag-reorder. Give a window target when either endpoint is an element.", dragProps, ["from", "to"]); } private static JsonObject BuildClickSchema() { JsonObject clickProps = WindowTargetProps(); - clickProps["at"] = EndpointSchema("Where to click: an element ({ by, value }) in the target window (its centre) or a pixel ({ x, y }). With a window target and a pixel endpoint, x/y are window-client-relative; without a window target they are absolute screen pixels."); + clickProps["at"] = EndpointSchema("Where to click: an element ({ by, value }) in the target window (its centre) or a pixel ({ x, y }). With a window target and a pixel endpoint, x/y are window-relative (window top-left + offset); without a window target they are absolute screen pixels."); clickProps["button"] = PropSchema("string", "Button: left | right | middle (default left)"); clickProps["count"] = PropSchema("integer", "Click count: 1 = single, 2 = double (default 1)"); return Tool("click", - "Click at a point; an element (by/value, clicked at its centre) or a pixel endpoint. With a window target, pixel endpoints are window-client-relative (target client origin + offset); without one they are absolute screen pixels (the space query/find bounds report). Move+click is dispatched atomically. Prefer invoke for buttons/menus; use click for element types invoke cannot drive or when you must target a pixel. Give a window target when 'at' is an element or when you want window-relative pixels.", + "Click at a point; an element (by/value, clicked at its centre) or a pixel endpoint. With a window target, pixel endpoints are window-relative (window top-left + offset, matching capture coordinates); without one they are absolute screen pixels (the space query/find bounds report). Move+click is dispatched atomically. Prefer invoke for buttons/menus; use click for element types invoke cannot drive or when you must target a pixel. Give a window target when 'at' is an element or when you want window-relative pixels.", clickProps, ["at"]); } diff --git a/src/Commands/ClickCommand.cs b/src/Commands/ClickCommand.cs index cc521dc..da32676 100644 --- a/src/Commands/ClickCommand.cs +++ b/src/Commands/ClickCommand.cs @@ -12,8 +12,8 @@ namespace MCEControl; /// Agent actuation command: a single mouse click at a target point (issue #122). The point is either an /// absolute screen pixel (x/y) or a UI Automation element in the target window (value, /// resolved to the centre of its bounds). When a window target is supplied and the endpoint is pixel -/// coordinates, x/y are interpreted as window-client-relative and translated to absolute -/// screen pixels via the target window's client origin. The move-then-click is dispatched atomically by +/// coordinates, x/y are interpreted as window-relative and translated to absolute +/// screen pixels via the target window's outer-rect origin. The move-then-click is dispatched atomically by /// so it cannot /// interleave with another command's mouse input (the hazard #113 warns about when a caller hand-rolls /// mt/lbc). Gated by and audited @@ -43,7 +43,7 @@ protected override string AuditDetails() => $"click at (by={By} value='{Value}' {X},{Y}) button={Button} count={Count} window handle={Handle} title='{Window}' process='{Process}'"; // A window is needed for element endpoints, and also for pixel endpoints when a target selector was - // provided (pixels become window-client-relative in that case). + // provided (pixels become window-relative in that case). protected override bool RequiresWindowTarget => HasWindowTarget || !string.IsNullOrEmpty(Value); protected override CommandResult ExecuteCore(WindowInfo? target) { @@ -72,10 +72,10 @@ protected override CommandResult ExecuteCore(WindowInfo? target) { /// /// Resolves the click point to an absolute screen pixel: the centre of an element (/ /// ) when is set, else the literal (, - /// ) when no window target is set, else the target window's client-origin offset plus + /// ) when no window target is set, else the target window's outer-rect origin plus /// (, ) when a window target is set. Returns false with a structured /// when the element can't be resolved (not found, ambiguous, stale window, - /// elevated target; #261), or when a targeted window disappears before its client origin can be read. + /// elevated target; #261), or when a targeted window disappears before its origin can be read. /// private bool TryResolvePoint(IntPtr hwnd, out (int X, int Y) point, out CommandResult? failure) { failure = null; @@ -84,14 +84,14 @@ private bool TryResolvePoint(IntPtr hwnd, out (int X, int Y) point, out CommandR point = (X, Y); return true; } - if (!TryGetWindowClientOrigin(hwnd, out (int X, int Y) origin)) { + if (!TryGetWindowOrigin(hwnd, out (int X, int Y) origin)) { point = default; failure = CommandResult.Fail(Cmd, "The target window disappeared before click coordinates could be resolved. Re-query and retry.", "window-closed", "stale-element"); return false; } - point = OffsetByClientOrigin((X, Y), origin); + point = OffsetByWindowOrigin((X, Y), origin); return true; } string by = string.IsNullOrEmpty(By) ? "name" : By; diff --git a/src/Commands/DragCommand.cs b/src/Commands/DragCommand.cs index 7559817..e4f58a5 100644 --- a/src/Commands/DragCommand.cs +++ b/src/Commands/DragCommand.cs @@ -13,8 +13,8 @@ namespace MCEControl; /// is either an absolute screen pixel (fromX/fromY, toX/toY) or a UI Automation element in /// the target window (fromValue/toValue, resolved to its bounds' centre), with optional /// intermediate waypoints (). When a window target is supplied and an endpoint is -/// pixel coordinates, that endpoint is interpreted as window-client-relative and translated to absolute -/// screen pixels via the target window's client origin. The whole gesture is dispatched atomically by +/// pixel coordinates, that endpoint is interpreted as window-relative and translated to absolute +/// screen pixels via the target window's outer-rect origin. The whole gesture is dispatched atomically by /// so it cannot interleave with another command's mouse input /// (the hazard #113 warns about when a caller hand-rolls lbd/mt/lbu). Gated by /// and audited (structurally, via @@ -49,7 +49,7 @@ protected override string AuditDetails() => $"drag from (by={FromBy} value='{FromValue}' {FromX},{FromY}) to (by={ToBy} value='{ToValue}' {ToX},{ToY}) window handle={Handle} title='{Window}' process='{Process}'"; // A window is needed for element endpoints, and also for pixel endpoints when a target selector was - // provided (pixel endpoints become window-client-relative in that case). + // provided (pixel endpoints become window-relative in that case). protected override bool RequiresWindowTarget => HasWindowTarget || !string.IsNullOrEmpty(FromValue) || !string.IsNullOrEmpty(ToValue); protected override CommandResult ExecuteCore(WindowInfo? target) { @@ -82,11 +82,11 @@ protected override CommandResult ExecuteCore(WindowInfo? target) { /// /// Resolves one drag endpoint to an absolute screen pixel: an element (by/value, centre of its /// bounds) when is set, else the literal (, - /// ) when no window target is set, else the target window's client-origin + /// ) when no window target is set, else the target window's outer-rect /// offset plus (, ) when a window target is set. Returns /// false with a structured when an element endpoint has no window or /// can't be resolved (not found, ambiguous, stale window, elevated target; #261), or when a targeted - /// window disappears before its client origin can be read. ("Drag + /// window disappears before its origin can be read. ("Drag /// start"/"Drag end") is prefixed onto the detail of EVERY failure category (via /// ) so a two-endpoint drag always says which end failed (#262 review). /// @@ -97,14 +97,14 @@ private static bool TryResolvePoint(IntPtr hwnd, string by, string value, int x, point = (x, y); return true; } - if (!TryGetWindowClientOrigin(hwnd, out (int X, int Y) origin)) { + if (!TryGetWindowOrigin(hwnd, out (int X, int Y) origin)) { point = default; failure = LabelEndpoint(endpoint, CommandResult.Fail(cmd, "The target window disappeared before drag coordinates could be resolved. Re-query and retry.", "window-closed", "stale-element")); return false; } - point = OffsetByClientOrigin((x, y), origin); + point = OffsetByWindowOrigin((x, y), origin); return true; } if (hwnd == IntPtr.Zero) { diff --git a/src/Commands/WindowTargetingAgentCommand.cs b/src/Commands/WindowTargetingAgentCommand.cs index 97c72de..e7477d6 100644 --- a/src/Commands/WindowTargetingAgentCommand.cs +++ b/src/Commands/WindowTargetingAgentCommand.cs @@ -2,7 +2,6 @@ // Published under the MIT License - Source on GitHub: https://github.com/tig/mcec using System; -using System.Drawing; using System.Xml.Serialization; namespace MCEControl; @@ -118,28 +117,27 @@ protected sealed override CommandResult ExecuteCore() { }; /// - /// Resolves a window's client-area origin in absolute screen pixels. Uses - /// (0,0) so coordinate endpoints inferred from a - /// window capture can be replayed without hand-adding monitor offsets. + /// Resolves a window's outer-rect origin in absolute screen pixels. Uses + /// so coordinate endpoints inferred from a window + /// capture can be replayed without hand-adding monitor offsets. /// - protected static bool TryGetWindowClientOrigin(IntPtr hwnd, out (int X, int Y) origin) { + protected static bool TryGetWindowOrigin(IntPtr hwnd, out (int X, int Y) origin) { origin = default; if (hwnd == IntPtr.Zero) { return false; } - Point clientOrigin = new(0, 0); - if (!AgentNativeMethods.ClientToScreen(hwnd, ref clientOrigin)) { + if (!AgentNativeMethods.GetWindowRect(hwnd, out NativeRect rect)) { return false; } - origin = (clientOrigin.X, clientOrigin.Y); + origin = (rect.Left, rect.Top); return true; } - /// Adds a client-area origin to a window-relative point to get an absolute screen pixel. - protected static (int X, int Y) OffsetByClientOrigin((int X, int Y) point, (int X, int Y) clientOrigin) => - (clientOrigin.X + point.X, clientOrigin.Y + point.Y); + /// Adds a window origin to a window-relative point to get an absolute screen pixel. + protected static (int X, int Y) OffsetByWindowOrigin((int X, int Y) point, (int X, int Y) windowOrigin) => + (windowOrigin.X + point.X, windowOrigin.Y + point.Y); // No Clone override: the shared selectors are value/string-typed, so the base // MemberwiseClone-based Command.Clone (#207) copies them by construction. diff --git a/tests/MCEControl.xUnit/Commands/UiaFailureProbeCommand.cs b/tests/MCEControl.xUnit/Commands/UiaFailureProbeCommand.cs index efe2755..9bd6cac 100644 --- a/tests/MCEControl.xUnit/Commands/UiaFailureProbeCommand.cs +++ b/tests/MCEControl.xUnit/Commands/UiaFailureProbeCommand.cs @@ -19,5 +19,5 @@ public sealed class UiaFailureProbeCommand : WindowTargetingAgentCommand { public static CommandResult? Failure(string cmd, UiaFailureKind kind) => UiaFailureFor(cmd, kind); public static (int X, int Y) OffsetPoint((int X, int Y) point, (int X, int Y) origin) => - OffsetByClientOrigin(point, origin); + OffsetByWindowOrigin(point, origin); } diff --git a/tests/MCEControl.xUnit/Commands/WindowTargetingAgentCommandTests.cs b/tests/MCEControl.xUnit/Commands/WindowTargetingAgentCommandTests.cs index 42e80c6..9fe8b5b 100644 --- a/tests/MCEControl.xUnit/Commands/WindowTargetingAgentCommandTests.cs +++ b/tests/MCEControl.xUnit/Commands/WindowTargetingAgentCommandTests.cs @@ -55,14 +55,14 @@ public void UiaFindFailureFor_FoundOrCleanMiss_IsNoFailure() { } [Fact] - public void OffsetByClientOrigin_AddsWindowClientOriginToWindowRelativePoint() { + public void OffsetByWindowOrigin_AddsWindowOriginToWindowRelativePoint() { (int X, int Y) point = UiaFailureProbeCommand.OffsetPoint((412, 88), (1923, 45)); Assert.Equal((2335, 133), point); } [Fact] - public void OffsetByClientOrigin_PreservesNegativeOffsets() { + public void OffsetByWindowOrigin_PreservesNegativeOffsets() { (int X, int Y) point = UiaFailureProbeCommand.OffsetPoint((-8, 0), (3200, 120)); Assert.Equal((3192, 120), point); diff --git a/tests/MCEControl.xUnit/Services/AgentServerTests.cs b/tests/MCEControl.xUnit/Services/AgentServerTests.cs index 4553d1d..5b273c2 100644 --- a/tests/MCEControl.xUnit/Services/AgentServerTests.cs +++ b/tests/MCEControl.xUnit/Services/AgentServerTests.cs @@ -159,7 +159,7 @@ public void Dispatch_ToolsList_ClickTool_DeclaresAtEndpoint() { Assert.True(props.ContainsKey("at")); Assert.True(props.ContainsKey("button")); Assert.True(props.ContainsKey("count")); - Assert.Contains("window-client-relative", props["at"]!["description"]!.GetValue(), StringComparison.Ordinal); + Assert.Contains("window-relative", props["at"]!["description"]!.GetValue(), StringComparison.Ordinal); List required = []; foreach (JsonNode? r in schema["required"]!.AsArray()) { @@ -317,8 +317,8 @@ public void Dispatch_ToolsList_DragTool_DeclaresFromToEndpoints() { Assert.True(props.ContainsKey("from")); Assert.True(props.ContainsKey("to")); Assert.True(props.ContainsKey("path")); - Assert.Contains("window-client-relative", props["from"]!["description"]!.GetValue(), StringComparison.Ordinal); - Assert.Contains("window-client-relative", props["to"]!["description"]!.GetValue(), StringComparison.Ordinal); + Assert.Contains("window-relative", props["from"]!["description"]!.GetValue(), StringComparison.Ordinal); + Assert.Contains("window-relative", props["to"]!["description"]!.GetValue(), StringComparison.Ordinal); List required = []; foreach (JsonNode? r in schema["required"]!.AsArray()) {