feat: add gesture sequences and calibrated pixel drags

This commit is contained in:
chenmingxuan committed 2026-11-02 11:53:27 +08:00
1 parent 2a3bbaa3fb
commit 479a185192
31 files changed
+2440 -44

No files matched your search

@@ -1,4 +1,570 @@
{
"action_sequence": {
"description": "Execute validated short gesture steps consecutively; stop at the first failure.",
"inputSchema": {
"$defs": {
"ClickStep": {
"additionalProperties": false,
"properties": {
"action": {
"const": "click",
"title": "Action",
"type": "string"
},
"target": {
"items": {
"type": "integer"
},
"maxItems": 2,
"minItems": 2,
"title": "Target",
"type": "array"
},
"target_description": {
"minLength": 1,
"title": "Target Description",
"type": "string"
},
"coordinate_space": {
"default": "normalized",
"enum": [
"normalized",
"pixel"
],
"title": "Coordinate Space",
"type": "string"
}
},
"required": [
"action",
"target",
"target_description"
],
"title": "ClickStep",
"type": "object"
},
"DragCalibration": {
"additionalProperties": false,
"properties": {
"axis": {
"enum": [
"x",
"y"
],
"title": "Axis",
"type": "string"
},
"mode": {
"description": "thumb moves a handle to a value; pan moves a scale under a fixed indicator.",
"enum": [
"thumb",
"pan"
],
"title": "Mode",
"type": "string"
},
"anchors": {
"items": {
"$ref": "#/$defs/ScaleAnchor"
},
"maxItems": 2,
"minItems": 2,
"title": "Anchors",
"type": "array"
},
"target_value": {
"title": "Target Value",
"type": "number"
},
"indicator_position": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"description": "Required for pan: the fixed indicator's observed axis position.",
"title": "Indicator Position"
}
},
"required": [
"axis",
"mode",
"anchors",
"target_value"
],
"title": "DragCalibration",
"type": "object"
},
"DragStep": {
"additionalProperties": false,
"properties": {
"start": {
"items": {
"type": "integer"
},
"maxItems": 2,
"minItems": 2,
"title": "Start",
"type": "array"
},
"target_description": {
"minLength": 1,
"title": "Target Description",
"type": "string"
},
"end": {
"anyOf": [
{
"items": {
"type": "integer"
},
"maxItems": 2,
"minItems": 2,
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "Exact endpoint; MUST be null when calibration is supplied.",
"title": "End"
},
"coordinate_space": {
"default": "normalized",
"enum": [
"normalized",
"pixel"
],
"title": "Coordinate Space",
"type": "string"
},
"duration_ms": {
"default": 300,
"maximum": 10000,
"minimum": 1,
"title": "Duration Ms",
"type": "integer"
},
"calibration": {
"anyOf": [
{
"$ref": "#/$defs/DragCalibration"
},
{
"type": "null"
}
],
"default": null,
"description": "Computes the endpoint from observed anchors; then set end=null."
},
"action": {
"const": "drag",
"title": "Action",
"type": "string"
}
},
"required": [
"start",
"target_description",
"action"
],
"title": "DragStep",
"type": "object"
},
"ScaleAnchor": {
"additionalProperties": false,
"properties": {
"position": {
"description": "Observed axis coordinate in the selected coordinate space.",
"title": "Position",
"type": "number"
},
"value": {
"description": "Observed scale value, in one consistent numeric unit.",
"title": "Value",
"type": "number"
}
},
"required": [
"position",
"value"
],
"title": "ScaleAnchor",
"type": "object"
},
"WaitStep": {
"additionalProperties": false,
"properties": {
"action": {
"const": "wait",
"title": "Action",
"type": "string"
},
"time_in_ms": {
"maximum": 10000,
"minimum": 0,
"title": "Time In Ms",
"type": "integer"
}
},
"required": [
"action",
"time_in_ms"
],
"title": "WaitStep",
"type": "object"
}
},
"properties": {
"steps": {
"items": {
"anyOf": [
{
"$ref": "#/$defs/ClickStep"
},
{
"$ref": "#/$defs/DragStep"
},
{
"$ref": "#/$defs/WaitStep"
}
]
},
"title": "Steps",
"type": "array"
}
},
"required": [
"steps"
],
"title": "action_sequenceArguments",
"type": "object"
},
"outputSchema": {
"$defs": {
"ActionCode": {
"description": "Machine-readable outcome classification for a device action.",
"enum": [
"OK",
"INVALID_ARGS",
"TARGET_NOT_FOUND",
"DEVICE_ERROR",
"PACKAGE_NOT_FOUND",
"TIMEOUT",
"UNSUPPORTED"
],
"title": "ActionCode",
"type": "string"
}
},
"description": "Outcome of one device action, independent of any transport or agent.",
"properties": {
"ok": {
"title": "Ok",
"type": "boolean"
},
"code": {
"$ref": "#/$defs/ActionCode"
},
"action": {
"title": "Action",
"type": "string"
},
"message": {
"title": "Message",
"type": "string"
},
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Detail"
},
"normalized_coordinates": {
"anyOf": [
{
"items": {
"type": "integer"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Normalized Coordinates"
},
"duration_ms": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
"ok",
"code",
"action",
"message"
],
"title": "ActionResult",
"type": "object"
}
},
"drag": {
"description": "Drag with exact coordinates or an observed two-anchor calibration.",
"inputSchema": {
"$defs": {
"DragCalibration": {
"additionalProperties": false,
"properties": {
"axis": {
"enum": [
"x",
"y"
],
"title": "Axis",
"type": "string"
},
"mode": {
"description": "thumb moves a handle to a value; pan moves a scale under a fixed indicator.",
"enum": [
"thumb",
"pan"
],
"title": "Mode",
"type": "string"
},
"anchors": {
"items": {
"$ref": "#/$defs/ScaleAnchor"
},
"maxItems": 2,
"minItems": 2,
"title": "Anchors",
"type": "array"
},
"target_value": {
"title": "Target Value",
"type": "number"
},
"indicator_position": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"description": "Required for pan: the fixed indicator's observed axis position.",
"title": "Indicator Position"
}
},
"required": [
"axis",
"mode",
"anchors",
"target_value"
],
"title": "DragCalibration",
"type": "object"
},
"ScaleAnchor": {
"additionalProperties": false,
"properties": {
"position": {
"description": "Observed axis coordinate in the selected coordinate space.",
"title": "Position",
"type": "number"
},
"value": {
"description": "Observed scale value, in one consistent numeric unit.",
"title": "Value",
"type": "number"
}
},
"required": [
"position",
"value"
],
"title": "ScaleAnchor",
"type": "object"
}
},
"properties": {
"start": {
"items": {
"type": "integer"
},
"title": "Start",
"type": "array"
},
"target_description": {
"title": "Target Description",
"type": "string"
},
"end": {
"anyOf": [
{
"items": {
"type": "integer"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "End"
},
"coordinate_space": {
"default": "normalized",
"enum": [
"normalized",
"pixel"
],
"title": "Coordinate Space",
"type": "string"
},
"duration_ms": {
"default": 300,
"title": "Duration Ms",
"type": "integer"
},
"calibration": {
"anyOf": [
{
"$ref": "#/$defs/DragCalibration"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"start",
"target_description"
],
"title": "dragArguments",
"type": "object"
},
"outputSchema": {
"$defs": {
"ActionCode": {
"description": "Machine-readable outcome classification for a device action.",
"enum": [
"OK",
"INVALID_ARGS",
"TARGET_NOT_FOUND",
"DEVICE_ERROR",
"PACKAGE_NOT_FOUND",
"TIMEOUT",
"UNSUPPORTED"
],
"title": "ActionCode",
"type": "string"
}
},
"description": "Outcome of one device action, independent of any transport or agent.",
"properties": {
"ok": {
"title": "Ok",
"type": "boolean"
},
"code": {
"$ref": "#/$defs/ActionCode"
},
"action": {
"title": "Action",
"type": "string"
},
"message": {
"title": "Message",
"type": "string"
},
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Detail"
},
"normalized_coordinates": {
"anyOf": [
{
"items": {
"type": "integer"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Normalized Coordinates"
},
"duration_ms": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
"ok",
"code",
"action",
"message"
],
"title": "ActionResult",
"type": "object"
}
},
"click": {
"description": "Tap at a 0-1000 normalized [x, y] coordinate.",
"inputSchema": {
@@ -99,6 +665,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -209,6 +780,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -316,6 +892,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -435,6 +1016,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -550,6 +1136,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -649,6 +1240,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -753,6 +1349,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -852,6 +1453,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -961,6 +1567,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -1060,6 +1671,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -1151,6 +1767,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
@@ -1253,6 +1874,11 @@
],
"default": null,
"title": "Duration Ms"
},
"metadata": {
"additionalProperties": true,
"title": "Metadata",
"type": "object"
}
},
"required": [
+371 -1
View File
@@ -50,6 +50,376 @@
}
}
},
"action_sequence": {
"type": "function",
"function": {
"name": "action_sequence",
"description": "[ACTION] Execute a short sequence of click, wait and drag steps in ONE call, in order, without model turns or screenshots between members. Use for temporary overlays: open the control, wait briefly, then operate it before it disappears. Use observed coordinates, or positions learned from an earlier observation of the same restored layout; element indices are not accepted. Each click/drag needs target_description. All members are validated before any input. The configured fast-action limit applies. The first failure stops the remainder; receipts identify completed and unexecuted members. Never replay the whole sequence after a partial result. Verify the final screen before claiming the intended result.",
"parameters": {
"properties": {
"steps": {
"description": "Ordered click, wait and drag members.",
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"action": {
"const": "click",
"title": "Action",
"type": "string"
},
"target": {
"items": {
"type": "integer"
},
"maxItems": 2,
"minItems": 2,
"title": "Target",
"type": "array"
},
"target_description": {
"minLength": 1,
"title": "Target Description",
"type": "string"
},
"coordinate_space": {
"default": "normalized",
"enum": [
"normalized",
"pixel"
],
"title": "Coordinate Space",
"type": "string"
}
},
"required": [
"action",
"target",
"target_description"
],
"title": "ClickStep",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"start": {
"items": {
"type": "integer"
},
"maxItems": 2,
"minItems": 2,
"title": "Start",
"type": "array"
},
"target_description": {
"minLength": 1,
"title": "Target Description",
"type": "string"
},
"end": {
"anyOf": [
{
"items": {
"type": "integer"
},
"maxItems": 2,
"minItems": 2,
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "Exact endpoint; MUST be null when calibration is supplied.",
"title": "End"
},
"coordinate_space": {
"default": "normalized",
"enum": [
"normalized",
"pixel"
],
"title": "Coordinate Space",
"type": "string"
},
"duration_ms": {
"default": 300,
"maximum": 10000,
"minimum": 1,
"title": "Duration Ms",
"type": "integer"
},
"calibration": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"axis": {
"enum": [
"x",
"y"
],
"title": "Axis",
"type": "string"
},
"mode": {
"description": "thumb moves a handle to a value; pan moves a scale under a fixed indicator.",
"enum": [
"thumb",
"pan"
],
"title": "Mode",
"type": "string"
},
"anchors": {
"items": {
"additionalProperties": false,
"properties": {
"position": {
"description": "Observed axis coordinate in the selected coordinate space.",
"title": "Position",
"type": "number"
},
"value": {
"description": "Observed scale value, in one consistent numeric unit.",
"title": "Value",
"type": "number"
}
},
"required": [
"position",
"value"
],
"title": "ScaleAnchor",
"type": "object"
},
"maxItems": 2,
"minItems": 2,
"title": "Anchors",
"type": "array"
},
"target_value": {
"title": "Target Value",
"type": "number"
},
"indicator_position": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"description": "Required for pan: the fixed indicator's observed axis position.",
"title": "Indicator Position"
}
},
"required": [
"axis",
"mode",
"anchors",
"target_value"
],
"title": "DragCalibration",
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"description": "Computes the endpoint from observed anchors; then set end=null."
},
"action": {
"const": "drag",
"title": "Action",
"type": "string"
}
},
"required": [
"start",
"target_description",
"action"
],
"title": "DragStep",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"action": {
"const": "wait",
"title": "Action",
"type": "string"
},
"time_in_ms": {
"maximum": 10000,
"minimum": 0,
"title": "Time In Ms",
"type": "integer"
}
},
"required": [
"action",
"time_in_ms"
],
"title": "WaitStep",
"type": "object"
}
]
},
"type": "array"
}
},
"required": [
"steps"
],
"type": "object"
}
}
},
"drag": {
"type": "function",
"function": {
"name": "drag",
"description": "[ACTION] Precisely drag a local slider or timeline. Use explicit start/end or calibration from TWO observed numeric scale anchors. coordinate_space is normalized (0..1000) or pixel (the current screenshot's actual pixels); pixel avoids quantization on a zoomed scale. Default duration_ms=300: slower is not always more precise and may trigger long-press recognition. Do not overshoot the endpoint automatically. For calibration, axis=x/y; mode=thumb moves the handle to target_value, mode=pan shifts the scale under indicator_position. Use one numeric unit, e.g. seconds since midnight, and two anchors from the CURRENT zoom/layout. Provide exactly one of end or calibration. Returned geometry is a plan/dispatch receipt, not proof of the selected value: compare the post-action scale/value and correct only the observed residual; zoom further if the pixel resolution is insufficient.",
"parameters": {
"properties": {
"start": {
"description": "Observed handle or pan starting point [x, y].",
"items": {
"type": "integer"
},
"type": "array"
},
"target_description": {
"description": "The slider, handle or timeline being dragged.",
"type": "string"
},
"end": {
"anyOf": [
{
"items": {
"type": "integer"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "Exact endpoint. Set null (or omit) when calibration is supplied; never compute both."
},
"coordinate_space": {
"default": "normalized",
"enum": [
"normalized",
"pixel"
],
"type": "string"
},
"duration_ms": {
"default": 300,
"type": "integer"
},
"calibration": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"axis": {
"enum": [
"x",
"y"
],
"title": "Axis",
"type": "string"
},
"mode": {
"description": "thumb moves a handle to a value; pan moves a scale under a fixed indicator.",
"enum": [
"thumb",
"pan"
],
"title": "Mode",
"type": "string"
},
"anchors": {
"items": {
"additionalProperties": false,
"properties": {
"position": {
"description": "Observed axis coordinate in the selected coordinate space.",
"title": "Position",
"type": "number"
},
"value": {
"description": "Observed scale value, in one consistent numeric unit.",
"title": "Value",
"type": "number"
}
},
"required": [
"position",
"value"
],
"title": "ScaleAnchor",
"type": "object"
},
"maxItems": 2,
"minItems": 2,
"title": "Anchors",
"type": "array"
},
"target_value": {
"title": "Target Value",
"type": "number"
},
"indicator_position": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"description": "Required for pan: the fixed indicator's observed axis position.",
"title": "Indicator Position"
}
},
"required": [
"axis",
"mode",
"anchors",
"target_value"
],
"title": "DragCalibration",
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"description": "Observed numeric anchors that compute the endpoint. When supplied, end MUST be null; otherwise set calibration null."
}
},
"required": [
"start",
"target_description"
],
"type": "object"
}
}
},
"input_text": {
"type": "function",
"function": {
@@ -105,7 +475,7 @@
"type": "function",
"function": {
"name": "swipe",
"description": "[ACTION] Perform a swipe, drag, or slider-adjustment gesture on the screen.\n\n• Directional Scrolling ('direction'): Recommended for general browsing and standard page scrolling in most scenarios. Automatically computes safe swipe vectors and adaptive duration, retains a ~40% visual overlap anchor for zero-omission traversal, and prevents inertial flings. Supports scoping to a sub-container via 'target'. If it fails on certain custom layouts, fall back to specifying exact coordinates ('start' and 'end') directly.\n• Precise Coordinate Gestures ('start', 'end'): Best for local, fine-grained interactions such as adjusting sliders/SeekBars (e.g., volume, brightness, progress bars), drag-and-drop / list reordering, or as a reliable fallback when directional scrolling fails on specific containers. Always drag slightly PAST the target position to overcome touch slop and reliably trigger the update. When setting a slider to Maximum (100%) or Minimum (0%), swipe fully to the extreme boundary.\n\nArgs:\n direction: Smart directional scrolling ('up', 'down', 'left', 'right'). Automatically computes safe swipe vectors, retaining 40% visual overlap: 'up' (reveal content below), 'down' (reveal content above), 'left', 'right'.\n start: Start normalized coordinates [start_x, start_y] in 0-1000 scale.\n end: End normalized coordinates [end_x, end_y] in 0-1000 scale.\n target: Optional target element index (e.g. 2) or container bounds [left, top, right, bottom] to scope the directional swipe within.\n gesture: Backward-compatible parameter: direction string OR custom coordinates list [start_x, start_y, end_x, end_y] in 0-1000 scale.\n target_description: What is being dragged, in a few words (e.g. 'brightness slider knob'). REQUIRED for coordinate gestures ('start'/'end' or a coordinates list); ignored for directional scrolling.\n duration: Optional gesture duration in milliseconds (default 800).",
"description": "[ACTION] Perform a swipe, drag, or slider-adjustment gesture on the screen.\n\n• Directional Scrolling ('direction'): Recommended for general browsing and standard page scrolling in most scenarios. Automatically computes safe swipe vectors and adaptive duration, retains a ~40% visual overlap anchor for zero-omission traversal, and prevents inertial flings. Supports scoping to a sub-container via 'target'. If it fails on certain custom layouts, fall back to specifying exact coordinates ('start' and 'end') directly.\n• Precise Coordinate Gestures ('start', 'end'): Use observed endpoints for local slider/timeline movement. Do not automatically overshoot: slow short gestures can trigger long-press recognition. Compare the actual post-action value/scale before adjusting again. When available, drag supports pixel coordinates and two-anchor calibration for fine positioning.\n\nArgs:\n direction: Smart directional scrolling ('up', 'down', 'left', 'right'). Automatically computes safe swipe vectors, retaining 40% visual overlap: 'up' (reveal content below), 'down' (reveal content above), 'left', 'right'.\n start: Start normalized coordinates [start_x, start_y] in 0-1000 scale.\n end: End normalized coordinates [end_x, end_y] in 0-1000 scale.\n target: Optional target element index (e.g. 2) or container bounds [left, top, right, bottom] to scope the directional swipe within.\n gesture: Backward-compatible parameter: direction string OR custom coordinates list [start_x, start_y, end_x, end_y] in 0-1000 scale.\n target_description: What is being dragged, in a few words (e.g. 'brightness slider knob'). REQUIRED for coordinate gestures ('start'/'end' or a coordinates list); ignored for directional scrolling.\n duration: Optional gesture duration in milliseconds (default 800).",
"parameters": {
"properties": {
"direction": {
+371 -1
View File
@@ -161,7 +161,7 @@
"type": "function",
"function": {
"name": "swipe",
"description": "[ACTION] Perform a swipe, drag, or slider-adjustment gesture on the screen.\n\n• Directional Scrolling ('direction'): Recommended for general browsing and standard page scrolling in most scenarios. Automatically computes safe swipe vectors and adaptive duration, retains a ~40% visual overlap anchor for zero-omission traversal, and prevents inertial flings. Supports scoping to a sub-container via 'target'. If it fails on certain custom layouts, fall back to specifying exact coordinates ('start' and 'end') directly.\n• Precise Coordinate Gestures ('start', 'end'): Best for local, fine-grained interactions such as adjusting sliders/SeekBars (e.g., volume, brightness, progress bars), drag-and-drop / list reordering, or as a reliable fallback when directional scrolling fails on specific containers. Always drag slightly PAST the target position to overcome touch slop and reliably trigger the update. When setting a slider to Maximum (100%) or Minimum (0%), swipe fully to the extreme boundary.\n\nArgs:\n direction: Smart directional scrolling ('up', 'down', 'left', 'right'). Automatically computes safe swipe vectors, retaining 40% visual overlap: 'up' (reveal content below), 'down' (reveal content above), 'left', 'right'.\n start: Start normalized coordinates [start_x, start_y] in 0-1000 scale.\n end: End normalized coordinates [end_x, end_y] in 0-1000 scale.\n target: Optional target element index (e.g. 2) or container bounds [left, top, right, bottom] to scope the directional swipe within.\n gesture: Backward-compatible parameter: direction string OR custom coordinates list [start_x, start_y, end_x, end_y] in 0-1000 scale.\n target_description: What is being dragged, in a few words (e.g. 'brightness slider knob'). REQUIRED for coordinate gestures ('start'/'end' or a coordinates list); ignored for directional scrolling.\n duration: Optional gesture duration in milliseconds (default 800).",
"description": "[ACTION] Perform a swipe, drag, or slider-adjustment gesture on the screen.\n\n• Directional Scrolling ('direction'): Recommended for general browsing and standard page scrolling in most scenarios. Automatically computes safe swipe vectors and adaptive duration, retains a ~40% visual overlap anchor for zero-omission traversal, and prevents inertial flings. Supports scoping to a sub-container via 'target'. If it fails on certain custom layouts, fall back to specifying exact coordinates ('start' and 'end') directly.\n• Precise Coordinate Gestures ('start', 'end'): Use observed endpoints for local slider/timeline movement. Do not automatically overshoot: slow short gestures can trigger long-press recognition. Compare the actual post-action value/scale before adjusting again. When available, drag supports pixel coordinates and two-anchor calibration for fine positioning.\n\nArgs:\n direction: Smart directional scrolling ('up', 'down', 'left', 'right'). Automatically computes safe swipe vectors, retaining 40% visual overlap: 'up' (reveal content below), 'down' (reveal content above), 'left', 'right'.\n start: Start normalized coordinates [start_x, start_y] in 0-1000 scale.\n end: End normalized coordinates [end_x, end_y] in 0-1000 scale.\n target: Optional target element index (e.g. 2) or container bounds [left, top, right, bottom] to scope the directional swipe within.\n gesture: Backward-compatible parameter: direction string OR custom coordinates list [start_x, start_y, end_x, end_y] in 0-1000 scale.\n target_description: What is being dragged, in a few words (e.g. 'brightness slider knob'). REQUIRED for coordinate gestures ('start'/'end' or a coordinates list); ignored for directional scrolling.\n duration: Optional gesture duration in milliseconds (default 800).",
"parameters": {
"type": "object",
"properties": {
@@ -310,5 +310,375 @@
]
}
}
},
"DRAG_TOOL": {
"type": "function",
"function": {
"name": "drag",
"description": "[ACTION] Precisely drag a local slider or timeline. Use explicit start/end or calibration from TWO observed numeric scale anchors. coordinate_space is normalized (0..1000) or pixel (the current screenshot's actual pixels); pixel avoids quantization on a zoomed scale. Default duration_ms=300: slower is not always more precise and may trigger long-press recognition. Do not overshoot the endpoint automatically. For calibration, axis=x/y; mode=thumb moves the handle to target_value, mode=pan shifts the scale under indicator_position. Use one numeric unit, e.g. seconds since midnight, and two anchors from the CURRENT zoom/layout. Provide exactly one of end or calibration. Returned geometry is a plan/dispatch receipt, not proof of the selected value: compare the post-action scale/value and correct only the observed residual; zoom further if the pixel resolution is insufficient.",
"parameters": {
"properties": {
"start": {
"description": "Observed handle or pan starting point [x, y].",
"items": {
"type": "integer"
},
"type": "array"
},
"target_description": {
"description": "The slider, handle or timeline being dragged.",
"type": "string"
},
"end": {
"anyOf": [
{
"items": {
"type": "integer"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "Exact endpoint. Set null (or omit) when calibration is supplied; never compute both."
},
"coordinate_space": {
"default": "normalized",
"enum": [
"normalized",
"pixel"
],
"type": "string"
},
"duration_ms": {
"default": 300,
"type": "integer"
},
"calibration": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"axis": {
"enum": [
"x",
"y"
],
"title": "Axis",
"type": "string"
},
"mode": {
"description": "thumb moves a handle to a value; pan moves a scale under a fixed indicator.",
"enum": [
"thumb",
"pan"
],
"title": "Mode",
"type": "string"
},
"anchors": {
"items": {
"additionalProperties": false,
"properties": {
"position": {
"description": "Observed axis coordinate in the selected coordinate space.",
"title": "Position",
"type": "number"
},
"value": {
"description": "Observed scale value, in one consistent numeric unit.",
"title": "Value",
"type": "number"
}
},
"required": [
"position",
"value"
],
"title": "ScaleAnchor",
"type": "object"
},
"maxItems": 2,
"minItems": 2,
"title": "Anchors",
"type": "array"
},
"target_value": {
"title": "Target Value",
"type": "number"
},
"indicator_position": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"description": "Required for pan: the fixed indicator's observed axis position.",
"title": "Indicator Position"
}
},
"required": [
"axis",
"mode",
"anchors",
"target_value"
],
"title": "DragCalibration",
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"description": "Observed numeric anchors that compute the endpoint. When supplied, end MUST be null; otherwise set calibration null."
}
},
"required": [
"start",
"target_description"
],
"type": "object"
}
}
},
"ACTION_SEQUENCE_TOOL": {
"type": "function",
"function": {
"name": "action_sequence",
"description": "[ACTION] Execute a short sequence of click, wait and drag steps in ONE call, in order, without model turns or screenshots between members. Use for temporary overlays: open the control, wait briefly, then operate it before it disappears. Use observed coordinates, or positions learned from an earlier observation of the same restored layout; element indices are not accepted. Each click/drag needs target_description. All members are validated before any input. The configured fast-action limit applies. The first failure stops the remainder; receipts identify completed and unexecuted members. Never replay the whole sequence after a partial result. Verify the final screen before claiming the intended result.",
"parameters": {
"properties": {
"steps": {
"description": "Ordered click, wait and drag members.",
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"action": {
"const": "click",
"title": "Action",
"type": "string"
},
"target": {
"items": {
"type": "integer"
},
"maxItems": 2,
"minItems": 2,
"title": "Target",
"type": "array"
},
"target_description": {
"minLength": 1,
"title": "Target Description",
"type": "string"
},
"coordinate_space": {
"default": "normalized",
"enum": [
"normalized",
"pixel"
],
"title": "Coordinate Space",
"type": "string"
}
},
"required": [
"action",
"target",
"target_description"
],
"title": "ClickStep",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"start": {
"items": {
"type": "integer"
},
"maxItems": 2,
"minItems": 2,
"title": "Start",
"type": "array"
},
"target_description": {
"minLength": 1,
"title": "Target Description",
"type": "string"
},
"end": {
"anyOf": [
{
"items": {
"type": "integer"
},
"maxItems": 2,
"minItems": 2,
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "Exact endpoint; MUST be null when calibration is supplied.",
"title": "End"
},
"coordinate_space": {
"default": "normalized",
"enum": [
"normalized",
"pixel"
],
"title": "Coordinate Space",
"type": "string"
},
"duration_ms": {
"default": 300,
"maximum": 10000,
"minimum": 1,
"title": "Duration Ms",
"type": "integer"
},
"calibration": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"axis": {
"enum": [
"x",
"y"
],
"title": "Axis",
"type": "string"
},
"mode": {
"description": "thumb moves a handle to a value; pan moves a scale under a fixed indicator.",
"enum": [
"thumb",
"pan"
],
"title": "Mode",
"type": "string"
},
"anchors": {
"items": {
"additionalProperties": false,
"properties": {
"position": {
"description": "Observed axis coordinate in the selected coordinate space.",
"title": "Position",
"type": "number"
},
"value": {
"description": "Observed scale value, in one consistent numeric unit.",
"title": "Value",
"type": "number"
}
},
"required": [
"position",
"value"
],
"title": "ScaleAnchor",
"type": "object"
},
"maxItems": 2,
"minItems": 2,
"title": "Anchors",
"type": "array"
},
"target_value": {
"title": "Target Value",
"type": "number"
},
"indicator_position": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"description": "Required for pan: the fixed indicator's observed axis position.",
"title": "Indicator Position"
}
},
"required": [
"axis",
"mode",
"anchors",
"target_value"
],
"title": "DragCalibration",
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"description": "Computes the endpoint from observed anchors; then set end=null."
},
"action": {
"const": "drag",
"title": "Action",
"type": "string"
}
},
"required": [
"start",
"target_description",
"action"
],
"title": "DragStep",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"action": {
"const": "wait",
"title": "Action",
"type": "string"
},
"time_in_ms": {
"maximum": 10000,
"minimum": 0,
"title": "Time In Ms",
"type": "integer"
}
},
"required": [
"action",
"time_in_ms"
],
"title": "WaitStep",
"type": "object"
}
]
},
"type": "array"
}
},
"required": [
"steps"
],
"type": "object"
}
}
}
}
@@ -25,6 +25,8 @@ async def test_operator_shell_tools():
"click": {"target": 1, "times": 1, "delay_ms": 100},
"input_text": {"text": "hello", "target": 1, "clear_exist": True},
"swipe": {"gesture": "up"},
"drag": {"start": [100, 200], "end": [100, 220], "target_description": "timeline"},
"action_sequence": {"steps": [{"action": "wait", "time_in_ms": 50}]},
"press_key": {"key": "ENTER"},
"manage_app": {"action": "launch", "app_name": "Settings"},
"wait_for_delay": {"time_in_ms": 1000},
@@ -54,8 +54,8 @@ from artemis.memory.transcript import (
# SHA-256 snapshots of the legacy system message with the fixed inputs below.
# Update these when an intentional template change alters the rendered prompt.
GOLDEN_EMPTY_PLAN = "e83c2c6cc1e28b04898e6602194cb2fbac67fe5a3aec8e109872c375f4e59944"
GOLDEN_SENTINEL_PLAN = "dba4eb2cdcd9389148ac3228c6e9e69fdbe8d407dec76b58aa65509ad768c392"
GOLDEN_EMPTY_PLAN = "23c3d6b5fc3694275e7015f7bd0901bde54aae54fec7a8c68d4f8529c65ef4bc"
GOLDEN_SENTINEL_PLAN = "370c53f1d088a4120c63fbaea6d895f121c683b8d3c8cc7d61105d6e1d223128"
SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8")
+6 -4
View File
@@ -131,10 +131,12 @@ def test_reduced_enums_stay_well_formed():
available_tools=OPERATOR_PROMPT_TOOLSET - {"manage_app", "wait_for_delay"},
)
assert (
"Physical device actions (`click`, `input_text`, `swipe`, `press_key`, `long_press`)" in out
"Physical device actions (`click`, `action_sequence`, `drag`, `input_text`, `swipe`, `press_key`, `long_press`)"
in out
)
assert (
"Turn-Ending Action (`click`, `swipe`, `input_text`, `long_press`, or `press_key`)" in out
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `swipe`, `input_text`, `long_press`, or `press_key`)"
in out
)
@@ -143,11 +145,11 @@ def test_full_set_enum_slots_render_verbatim():
template = load_operator_prompts()["main_template"]
out = apply_operator_prompt_contract(template)
assert (
"Physical device actions (`click`, `input_text`, `swipe`, `press_key`,"
"Physical device actions (`click`, `action_sequence`, `drag`, `input_text`, `swipe`, `press_key`,"
" `manage_app`, `wait_for_delay`, `long_press`)" in out
)
assert (
"Turn-Ending Action (`click`, `swipe`, `input_text`, `long_press`,"
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `swipe`, `input_text`, `long_press`,"
" `press_key`, `manage_app`, or `wait_for_delay`)" in out
)
assert "Helper/Subagent tools (`ask_explorer`, `ask_diagnoser`, `video_analyzer`)" in out
+5 -1
View File
@@ -83,6 +83,8 @@ def test_tool_declarations_match_fixture():
"LONG_PRESS_TOOL": "long_press",
"INPUT_TEXT_TOOL": "input_text",
"SWIPE_TOOL": "swipe",
"DRAG_TOOL": "drag",
"ACTION_SEQUENCE_TOOL": "action_sequence",
"PRESS_KEY_TOOL": "press_key",
"MANAGE_APP_TOOL": "manage_app",
"WAIT_FOR_DELAY_TOOL": "wait_for_delay",
@@ -121,6 +123,8 @@ def test_validator_declaration_order_is_stable():
"long_press",
"input_text",
"swipe",
"drag",
"action_sequence",
"press_key",
"read_note",
"list_notes",
@@ -159,7 +163,7 @@ def test_flash_declarations_are_the_operator_shells_projected():
decl_props = declaration.parameters["properties"]
assert list(decl_props) == list(shell_props), spec.name
for param, schema in shell_props.items():
assert decl_props[param] == _without_optional_noise(schema), (
assert _without_optional_noise(decl_props[param]) == _without_optional_noise(schema), (
f"'{spec.name}.{param}' diverges between the operator shell and the"
" Flash declaration."
)
+400
View File
@@ -0,0 +1,400 @@
"""Behavioral regressions for short-lived controls and measured scale movement."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from artemis.mcp.action_server import build_action_server
from artemis.mcp.action_session import ActionSession
from artemis.mcp.actuators import MockActuator
from artemis.mcp.gestures import DragRequest, plan_drag
def test_pan_uses_decreasing_observed_time_scale_without_overshoot():
request = DragRequest(
start=[190, 1059],
coordinate_space="pixel",
target_description="timeline",
calibration={
"axis": "y",
"mode": "pan",
"anchors": [
{"position": 1075, "value": 38400},
{"position": 1187, "value": 37800},
],
"target_value": 37800,
"indicator_position": 1215,
},
)
planned = plan_drag(request, 720, 1600)
assert planned["coordinates"] == [190, 1059, 190, 1087]
assert planned["calibration"]["value_per_pixel"] == pytest.approx(-600 / 112)
assert planned["calibration"]["verification"] == "pending_observation"
@pytest.mark.parametrize(
"axis,start,expected", [("x", [80, 200], [125, 200]), ("y", [200, 80], [200, 125])]
)
def test_thumb_maps_value_to_the_requested_axis(axis, start, expected):
planned = plan_drag(
DragRequest(
start=start,
coordinate_space="pixel",
target_description="slider",
calibration={
"axis": axis,
"mode": "thumb",
"anchors": [
{"position": 100, "value": 0},
{"position": 200, "value": 100},
],
"target_value": 25,
},
),
720,
1600,
)
assert planned["coordinates"] == [*start, *expected]
@pytest.mark.asyncio
async def test_pixel_drag_is_not_quantized_through_normalized_coordinates():
actuator = MockActuator(width=720, height=1600)
session = await ActionSession(build_action_server(actuator)).start()
try:
result = await session.call(
"drag",
{
"start": [191, 1001],
"end": [191, 1032],
"coordinate_space": "pixel",
"target_description": "timeline",
},
)
assert result.ok
assert result.metadata["coordinates"] == [191, 1001, 191, 1032]
assert actuator.action_history == [
{
"action": "swipe",
"start": (191, 1001),
"end": (191, 1032),
"duration_ms": 300,
}
]
finally:
await session.aclose()
def steps():
return [
{"action": "click", "target": [200, 900], "target_description": "open scale"},
{"action": "wait", "time_in_ms": 1},
{
"action": "drag",
"start": [190, 1001],
"end": [190, 1032],
"coordinate_space": "pixel",
"target_description": "scale handle",
},
]
@pytest.mark.asyncio
async def test_sequence_has_no_observation_between_inputs_and_returns_member_receipts():
actuator = MockActuator(width=720, height=1600)
actuator.controller.tap_at = AsyncMock(return_value=MagicMock(error=None))
actuator.controller.swipe_coords = AsyncMock(return_value=None)
actuator.take_screenshot = AsyncMock()
result = await actuator.action_sequence(steps())
assert result.ok
actuator.controller.tap_at.assert_awaited_once_with(144, 1440)
actuator.controller.swipe_coords.assert_awaited_once_with(190, 1001, 190, 1032, 300)
actuator.take_screenshot.assert_not_awaited()
receipts = result.metadata["sequence_results"]
assert [r["execution"] for r in receipts] == ["completed"] * 3
assert receipts[0]["ended_ms"] <= receipts[1]["started_ms"] <= receipts[2]["started_ms"]
@pytest.mark.asyncio
async def test_late_invalid_member_prevents_even_the_first_input():
actuator = MockActuator(width=720, height=1600)
actuator.controller.tap_at = AsyncMock()
invalid = steps()
invalid[-1]["end"] = [720, 1032]
result = await actuator.action_sequence(invalid)
assert not result.ok and result.code.value == "INVALID_ARGS"
actuator.controller.tap_at.assert_not_awaited()
@pytest.mark.asyncio
async def test_failure_keeps_completed_members_and_does_not_dispatch_the_remainder():
actuator = MockActuator(width=720, height=1600)
actuator.controller.tap_at = AsyncMock(
side_effect=[MagicMock(error=None), RuntimeError("transport lost")]
)
member = {"action": "click", "target": [200, 900], "target_description": "control"}
result = await actuator.action_sequence([member, member, member])
assert not result.ok
assert actuator.controller.tap_at.await_count == 2
assert [r["execution"] for r in result.metadata["sequence_results"]] == [
"completed",
"unknown",
"not_started",
]
assert result.metadata["execution"] == "partial"
@pytest.mark.asyncio
async def test_cancellation_is_not_swallowed_or_retried():
actuator = MockActuator(width=720, height=1600)
actuator.ctx.data_engine = MagicMock(current_step_id="step")
actuator.controller.swipe_coords = AsyncMock(side_effect=asyncio.CancelledError)
with pytest.raises(asyncio.CancelledError):
await actuator.action_sequence(steps())
assert len(actuator.action_history) == 1
assert actuator.controller.swipe_coords.await_count == 1
receipts = [
c.kwargs["payload"]["receipt"]
for c in actuator.ctx.data_engine.record_trace.call_args_list
if "receipt" in c.kwargs["payload"]
]
assert [r["execution"] for r in receipts] == ["completed", "completed", "unknown"]
@pytest.mark.asyncio
async def test_pause_at_member_boundary_does_not_dispatch_later_inputs(tmp_path):
from artemis.runtime.task_control import TaskControl
actuator = MockActuator(width=720, height=1600)
control = object.__new__(TaskControl)
control.directory = tmp_path
actuator.ctx.task_control = control
async def tap(*_):
(tmp_path / "pause.request").write_text("{}")
return SimpleNamespace(error=None)
actuator.controller.tap_at = AsyncMock(side_effect=tap)
actuator.controller.swipe_coords = AsyncMock()
result = await actuator.action_sequence(steps())
assert not result.ok
assert result.metadata["completed_steps"] == 1
assert result.metadata["sequence_results"][1]["execution"] == "not_started"
actuator.controller.swipe_coords.assert_not_awaited()
@pytest.mark.asyncio
async def test_over_limit_is_rejected_before_dispatch(monkeypatch):
monkeypatch.setattr(
"artemis.config.agent.load_agent_config",
lambda: SimpleNamespace(
pro=SimpleNamespace(execution=SimpleNamespace(max_burst_actions=2)),
),
)
actuator = MockActuator(width=720, height=1600)
result = await actuator.action_sequence(steps())
assert not result.ok and "limit of 2" in result.message
assert actuator.action_history == []
@pytest.mark.parametrize("values", [(0, 0), (1e308, -1e308), (float("nan"), 1), (True, 5)])
def test_degenerate_and_nonfinite_calibration_is_rejected(values):
with pytest.raises(ValueError):
request = DragRequest(
start=[100, 100],
coordinate_space="pixel",
target_description="timeline",
calibration={
"axis": "y",
"mode": "pan",
"indicator_position": 100,
"anchors": [
{"position": 100, "value": values[0]},
{"position": 200, "value": values[1]},
],
"target_value": 3,
},
)
plan_drag(request, 720, 1600)
def test_flash_and_pro_schemas_preserve_null_for_calculated_endpoints():
from langchain_core.utils.function_calling import convert_to_openai_tool
from artemis.mcp.action_specs import tool_declaration, operator_shell_tool
flash = tool_declaration("drag").parameters
pro = convert_to_openai_tool(operator_shell_tool("drag"))["function"]["parameters"]
assert flash == pro
assert {"type": "null"} in flash["properties"]["end"]["anyOf"]
@pytest.mark.asyncio
async def test_conflicting_endpoint_has_actionable_error_and_no_input():
actuator = MockActuator(width=720, height=1600)
result = await actuator.drag(
start=[190, 1200],
end=[190, 1100],
coordinate_space="pixel",
target_description="timeline",
calibration={
"axis": "y",
"mode": "pan",
"anchors": [{"position": 1000, "value": 60}, {"position": 1100, "value": 0}],
"target_value": 30,
"indicator_position": 1050,
},
)
assert not result.ok and "set end=null" in result.message
assert actuator.action_history == []
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["flash", "pro"])
async def test_each_mode_exposes_the_receipt_to_its_next_model_turn(mode):
from artemis.agents.validator.action_execution import exec_action
from artemis.mcp.action_executor import McpActionExecutor
from artemis.mcp.action_types import ActionResult, ObserveResult
ctx = SimpleNamespace(
data_engine=None, device=SimpleNamespace(device_width=720, device_height=1600)
)
receipt = {
"sequence_results": [{"action": "click", "execution": "completed"}],
"execution": "partial",
}
session = MagicMock()
session.call = AsyncMock(
return_value=ActionResult.failure("action_sequence", "Interrupted", metadata=receipt)
)
args = {"steps": steps()}
if mode == "pro":
action = {"action": "action_sequence", "args": args}
ok, _ = await exec_action(ctx, session, action)
assert not ok
assert action["execution"] == "partial"
from artemis.utils.task_tree import format_result_clean
text = format_result_clean({"status": "failed", "execution": [action]})
else:
executor = McpActionExecutor(ctx, actuator=MagicMock())
executor._session_or_start = AsyncMock(return_value=session)
executor._observe_after_action = AsyncMock(return_value=(ObserveResult(ok=True), True))
result = await executor._execute_device_action(
"action_sequence",
"action_sequence",
args,
"gesture",
SimpleNamespace(latest_screenshot=None, indexed_elements=[]),
)
assert result.status == "error"
executor._observe_after_action.assert_awaited_once()
text = result.text_summary
assert '"execution": "partial"' in text
assert '"execution": "completed"' in text
@pytest.mark.asyncio
async def test_pro_translation_and_wire_dispatch_preserve_exact_pixel_drag():
from artemis.agents.operator.operator import OperatorNode
from artemis.agents.validator.action_execution import exec_action
actuator = MockActuator(width=720, height=1600)
node = object.__new__(OperatorNode)
node.ctx = actuator.ctx
request = {
"start": [191, 1001],
"end": [191, 1032],
"coordinate_space": "pixel",
"target_description": "timeline",
}
state = SimpleNamespace(operator_raw_data={"width": 720, "height": 1600})
actions, error = node._translate_and_validate_tool_inner(
{"name": "drag", "args": request}, state
)
assert error is None
assert actions[0]["coordinates"] == [191, 1001, 191, 1032]
assert actions[0]["normalized_coordinates"] == [265, 626, 265, 645]
session = await ActionSession(build_action_server(actuator)).start()
try:
ok, _ = await exec_action(actuator.ctx, session, actions[0])
assert ok
assert actuator.action_history[-1]["end"] == (191, 1032)
assert actions[0]["execution"] == "completed"
finally:
await session.aclose()
@pytest.mark.parametrize(
"change",
[
{"start": [True, 10]},
{"duration_ms": 0},
{"target_description": " "},
{"end": None},
{"coordinate_space": "screen"},
],
)
def test_bad_drag_arguments_are_rejected(change):
args = {"start": [10, 20], "end": [20, 30], "target_description": "slider", **change}
with pytest.raises(ValueError):
DragRequest.model_validate(args)
def test_pixel_history_under_1000_still_uses_its_actual_screen_geometry():
from artemis.utils.coordinates import normalize_step_actions
action = {
"action": "drag",
"coordinates": [191, 900, 191, 920],
"coordinate_space": "pixel",
"screen_size": [720, 1600],
}
step = normalize_step_actions({"action_taken": action})
assert step["action_taken"]["coordinates"] == [191, 900, 191, 920]
assert step["action_taken"]["normalized_coordinates"] == [265, 562, 265, 575]
def test_overlay_uses_executed_pixels_instead_of_original_request_units():
import io
from PIL import Image
from artemis.utils.visualization import overlay_action_on_screenshot
buffer = io.BytesIO()
Image.new("RGB", (720, 1600), "white").save(buffer, format="PNG")
action = {
"action": "drag",
"coordinates": [190, 900, 190, 930],
"coordinate_space": "pixel",
"args": {"start": [264, 563], "end": [264, 581], "coordinate_space": "normalized"},
}
drawn = overlay_action_on_screenshot(buffer.getvalue(), action)
assert drawn is not None
result = Image.open(io.BytesIO(drawn))
red, green, blue = result.getpixel((190, 915))
assert red > 150 and green < 120 and blue < 120
assert min(result.getpixel((137, 1450))) > 240
def test_sequence_overlay_does_not_draw_unexecuted_targets():
from artemis.utils.visualization import draw_action_overlay_on_image
raw = b"unused original image"
assert (
draw_action_overlay_on_image(
raw,
"action_sequence",
{
"sequence_results": [
{
"action": "click",
"coordinates": [190, 900],
"coordinate_space": "pixel",
"execution": "not_started",
},
]
},
)
== raw
)
@@ -121,11 +121,20 @@ def test_stop_server_when_not_running(monkeypatch):
"""Verify stop_server returns early when no server is running."""
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: False)
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [])
# A simulated offline server must not touch the user's live metadata or leases.
clear_info = MagicMock()
cleanup_locks = MagicMock(return_value=0)
monkeypatch.setattr("artemis.runtime.server_lifecycle.clear_server_info", clear_info)
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.DeviceExecutionLock.cleanup_stale_locks", cleanup_locks
)
success, msg, stopped = stop_server(8000)
assert success is True
assert stopped == []
assert "No active server" in msg
clear_info.assert_called_once_with(port=8000)
cleanup_locks.assert_called_once_with()
def test_server_main_parses_non_default_bind_address(monkeypatch):