Files
Artemis-Eapil/tests/unit/mcp/test_action_specs.py
T
somew1nd 67248c887e feat: improve task execution, verification and packaging
Add execution incidents, action bursts and shared Flash history.

Expose verification and Explorer settings with streamed check results.

Support graceful cancellation, recording recovery and bundled UI resources.

Add CI quality checks and clean up comments and documentation.
2026-09-02 21:45:51 -07:00

228 lines
8.4 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The canonical action manifest's conformance suite.
Two kinds of protection:
* **Fixture pins** freeze the exact schema each model surface receives (operator
shells, Validator/Flash declarations, the action server manifest). A schema change
must be a conscious act: regenerate the fixture in the same commit and say why.
* **Bridge derivation** re-derives every cross-dialect parameter difference from the
generated schemas and requires it to be declared in ``ActionSpec.param_bridge``.
Undeclared divergence between what one agent sees and what another sees -- the
historical definition of drift -- fails here instead of shipping.
"""
import json
from pathlib import Path
from langchain_core.utils.function_calling import convert_to_openai_tool
import pytest
from artemis.agents.operator.prompts import (
_PHYSICAL_ACTIONS_ORDER,
_TURN_ENDING_ORDER,
OPERATOR_PROMPT_TOOLSET,
)
from artemis.agents.validator.tool_declarations import VALIDATOR_TOOLS_DECLARATION
from artemis.mcp.action_manifest import (
INTERNAL_ACTIONS,
OPTIONAL_ACTIONS,
REQUIRED_ACTIONS,
)
from artemis.mcp.action_names import OPERATOR_ACTION_TO_CANONICAL
from artemis.mcp.action_specs import (
ACTION_SPECS,
OPERATOR_SHELL_ORDER,
operator_shell_tool,
tool_declaration,
wire_dialects,
)
from artemis.mcp.actuators.base import Actuator
FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" / "action_surfaces"
FIXTURE_HINT = (
"The generated schema differs from the pinned fixture. If this change is"
" intentional, regenerate the fixture in the same commit and explain the schema"
" change in the commit message; models see this schema directly."
)
def _fixture(name: str) -> dict:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
# --- Fixture pins --------------------------------------------------------------------
def test_operator_shells_match_fixture():
expected = _fixture("operator_shells.json")
assert set(expected) == set(OPERATOR_SHELL_ORDER)
for name in OPERATOR_SHELL_ORDER:
generated = convert_to_openai_tool(operator_shell_tool(name))
assert generated == expected[name], f"operator shell '{name}': {FIXTURE_HINT}"
def test_tool_declarations_match_fixture():
expected = _fixture("tool_declarations.json")
by_constant = {
"CLICK_TOOL": "click",
"CLICK_SEQUENCE_TOOL": "click_sequence",
"LONG_PRESS_TOOL": "long_press",
"INPUT_TEXT_TOOL": "input_text",
"SWIPE_TOOL": "swipe",
"PRESS_KEY_TOOL": "press_key",
"MANAGE_APP_TOOL": "manage_app",
"WAIT_FOR_DELAY_TOOL": "wait_for_delay",
}
assert set(expected) == set(by_constant)
for constant, name in by_constant.items():
assert dict(tool_declaration(name)) == expected[constant], (
f"declaration '{name}': {FIXTURE_HINT}"
)
@pytest.mark.asyncio
async def test_action_server_manifest_matches_fixture():
from artemis.mcp.action_server import build_action_server
from artemis.mcp.actuators import MockActuator
expected = _fixture("action_server_manifest.json")
server = build_action_server(MockActuator())
tools = await server.list_tools()
generated = {
t.name: {
"description": t.description,
"inputSchema": t.inputSchema,
"outputSchema": t.outputSchema,
}
for t in tools
}
assert set(generated) == set(expected)
for name in expected:
assert generated[name] == expected[name], f"server tool '{name}': {FIXTURE_HINT}"
def test_validator_declaration_order_is_stable():
assert [d.name for d in VALIDATOR_TOOLS_DECLARATION] == [
"click",
"long_press",
"input_text",
"swipe",
"press_key",
"read_note",
"list_notes",
"manage_app",
"wait_for_delay",
]
# --- Bridge derivation: dialect differences must be declared -------------------------
def _operator_param_names(name: str) -> list[str]:
schema = convert_to_openai_tool(operator_shell_tool(name))
return list(schema["function"]["parameters"].get("properties", {}))
def _declaration_param_names(name: str) -> list[str]:
return list(tool_declaration(name).parameters.get("properties", {}))
def test_param_bridge_is_exact():
"""Every operator<->declaration parameter difference is declared, none invented."""
for spec in ACTION_SPECS.values():
if spec.operator is None or spec.declaration is None:
assert spec.param_bridge == {}, (
f"'{spec.name}' declares a param_bridge without both dialects."
)
continue
operator_names = _operator_param_names(spec.name)
declaration_names = _declaration_param_names(spec.name)
assert set(spec.param_bridge) == set(operator_names), (
f"'{spec.name}': param_bridge keys must cover exactly the operator"
f" params. bridge={sorted(spec.param_bridge)} operator={operator_names}"
)
assert set(spec.param_bridge.values()) == set(declaration_names), (
f"'{spec.name}': param_bridge values must cover exactly the declaration"
f" params. bridge={sorted(spec.param_bridge.values())}"
f" declaration={declaration_names}"
)
def test_renamed_params_are_documented():
"""A cross-dialect rename is a deliberate difference; it must carry prose."""
for spec in ACTION_SPECS.values():
renames = {k: v for k, v in spec.param_bridge.items() if k != v}
if renames:
assert spec.differences, (
f"'{spec.name}' renames {renames} across dialects but documents no differences."
)
# --- Manifest coverage ---------------------------------------------------------------
def test_specs_cover_exactly_the_llm_reachable_device_actions():
assert set(ACTION_SPECS) == (REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
assert not set(ACTION_SPECS) & INTERNAL_ACTIONS
def test_operator_shell_order_covers_operator_dialects():
with_operator = {s.name for s in ACTION_SPECS.values() if s.operator is not None}
assert set(OPERATOR_SHELL_ORDER) == with_operator
assert len(OPERATOR_SHELL_ORDER) == len(set(OPERATOR_SHELL_ORDER))
def test_every_wire_dialect_matches_the_actuator_protocol():
for spec in wire_dialects():
method = getattr(Actuator, spec.name, None)
assert callable(method), f"wire dialect '{spec.name}' has no corresponding Actuator method."
def test_prompt_enum_orders_come_from_the_manifest():
assert _PHYSICAL_ACTIONS_ORDER == OPERATOR_SHELL_ORDER
assert set(_TURN_ENDING_ORDER) == set(OPERATOR_SHELL_ORDER)
assert set(OPERATOR_SHELL_ORDER) <= OPERATOR_PROMPT_TOOLSET
def test_operator_verbs_lower_onto_manifest_actions():
"""The Operator's internal decision verbs resolve to canonical manifest names."""
unresolved = set(OPERATOR_ACTION_TO_CANONICAL.values()) - set(ACTION_SPECS)
assert not unresolved, (
f"action_names maps operator verbs onto names the manifest does not know:"
f" {sorted(unresolved)}"
)
def test_registry_wait_for_delay_seconds_drift_stays_dead():
"""Guards against re-growing a shadow device-action registration.
``artemis/tools/actions/device_actions.py`` used to shadow-register a second
``wait_for_delay`` taking *seconds* while every prompt teaches milliseconds
(the last such surface, ``artemis/tools/mobile/exec_tools.py``, is deleted).
The ToolRegistry must never again carry a ``wait_for_delay`` whose unit
disagrees with the manifest's ``time_in_ms``.
"""
from artemis.tools.base import ToolRegistry
tool = ToolRegistry.get("wait_for_delay")
if tool is None:
return # Not registered at all: nothing to drift.
fields = set(tool.args_schema.model_json_schema().get("properties", {}))
assert "seconds" not in fields
assert "time_in_ms" in fields