feat: harden agent verification and runtime contracts

Introduce two-gate checker settlement, explicit graph visibility and checkpoints, structured LLM reliability, centralized action specifications, and corresponding tests and design documentation.
This commit is contained in:
somew1nd committed 2026-08-31 20:42:24 -07:00
1 parent ddfa420945
commit cbe8f4f0c6
106 files changed
+11542 -4445

No files matched your search

@@ -0,0 +1,176 @@
# 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.
"""§1.3 assembly discipline: prompts are functions of configuration x scenario.
Both check gates off => zero context pollution; a plan carrying check lines
gets the behavior explainer regardless of switches."""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from artemis.agents.operator.prompts import (
CheckItemsExplainerPromptComponent,
FeedbackPromptComponent,
PromptBuilder,
TemplatePromptComponent,
)
from artemis.agents.planner.planner import build_planner_system_blocks
from artemis.context import ArtemisContext, ExecutionSetup
from artemis.graph.state import State
PLANNER_JSON = Path(__file__).resolve().parents[3] / "artemis/agents/planner/planner.json"
OPERATOR_JSON = Path(__file__).resolve().parents[3] / "artemis/agents/operator/operator.json"
def _planner_data():
return json.loads(PLANNER_JSON.read_text(encoding="utf-8"))
def test_planner_blocks_without_checks_are_byte_identical_to_legacy():
data = _planner_data()
baseline = list(data["modes"]["initial_plan"]["system"])
assert build_planner_system_blocks(data, "initial_plan", include_checks=False) == baseline
# And the disabled assembly never references the check blocks
rendered = "\n\n".join(
data["blocks"][b]
for b in build_planner_system_blocks(data, "initial_plan", include_checks=False)
)
assert "- verify:" not in rendered
assert "- assert:" not in rendered
def test_planner_blocks_with_checks_mount_generation_and_audit():
data = _planner_data()
initial = build_planner_system_blocks(data, "initial_plan", include_checks=True)
assert initial[-1] == "check_generation"
validator = build_planner_system_blocks(data, "validator", include_checks=True)
assert validator[-1] == "check_audit"
generation = data["blocks"]["check_generation"]
# Restraint, honesty about post-hoc auditing, and no silent ordering promises
assert "restraint" in generation.lower() or "Do NOT attach" in generation
assert "post-hoc" in generation.lower()
assert "Never silently promise" in generation
audit = data["blocks"]["check_audit"]
assert "REJECT" in audit
def _mock_ctx(tmp_path=None, plan: str | None = None, **setup_kwargs):
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = ExecutionSetup(**setup_kwargs) if setup_kwargs is not None else None
if plan is not None and tmp_path is not None:
notes = tmp_path / "notes"
notes.mkdir(parents=True, exist_ok=True)
(notes / "task_plan.md").write_text(plan, encoding="utf-8")
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = tmp_path
else:
ctx.data_engine = None
return ctx
def _state(**overrides):
state = MagicMock(spec=State)
state.operator_feedback = None
for k, v in overrides.items():
setattr(state, k, v)
return state
@pytest.mark.asyncio
async def test_explainer_renders_iff_plan_has_check_lines(tmp_path):
component = CheckItemsExplainerPromptComponent()
# Plan without check lines -> nothing rendered
builder = PromptBuilder()
ctx = _mock_ctx(tmp_path, plan="- [ ] G\n")
await component(builder, _state(), ctx)
assert builder.human_parts == []
# Plan with check lines -> explainer rendered
builder2 = PromptBuilder()
ctx2 = _mock_ctx(tmp_path, plan="- [ ] G\n - verify: V\n")
await component(builder2, _state(), ctx2)
joined = "\n".join(p for p in builder2.human_parts if isinstance(p, str))
assert "check lines" in joined
assert "independent Checker" in joined
assert "automatically restored" in joined
@pytest.mark.asyncio
async def test_explainer_is_content_driven_not_switch_driven(tmp_path):
"""A resumed old plan with check lines still gets the explanation even when
every check switch is off."""
component = CheckItemsExplainerPromptComponent()
builder = PromptBuilder()
ctx = _mock_ctx(tmp_path, plan="- [ ] G\n - assert: A\n", disable_checker=True)
await component(builder, _state(), ctx)
joined = "\n".join(p for p in builder.human_parts if isinstance(p, str))
assert "check lines" in joined
async def _render_main_template(**setup_kwargs) -> str:
component = TemplatePromptComponent()
builder = PromptBuilder()
ctx = _mock_ctx(**setup_kwargs)
ctx.actuator = None
state = _state(initial_goal="G")
prompts = json.loads(OPERATOR_JSON.read_text(encoding="utf-8"))
await component(builder, state, ctx, prompts=prompts, plan_and_history="")
return "".join(builder.system_parts) + (builder.human_footer or "")
@pytest.mark.asyncio
async def test_main_template_diagnosis_trigger_gated_on_verification():
"""The rejection/finding diagnosis trigger is a dead instruction unless a
mechanism that can produce rejections or findings is active (§1.3)."""
# Factory default: checks off (master alias) AND planner validation off.
default = await _render_main_template(disable_checker=True, disable_planner_validation=True)
assert "Verification Finding" not in default
assert "Ambiguous Validation Rejection" not in default
# Checks on -> the trigger is real and must be documented.
with_checks = await _render_main_template(
disable_checker=False, disable_planner_validation=True
)
assert "Ambiguous Validation Rejection or Verification Finding" in with_checks
# Planner validation alone can also produce rejections.
with_validation = await _render_main_template(
disable_checker=True, disable_planner_validation=False
)
assert "Ambiguous Validation Rejection or Verification Finding" in with_validation
@pytest.mark.asyncio
async def test_check_feedback_component_appends_findings_only_when_present():
component = FeedbackPromptComponent()
builder = PromptBuilder()
await component(builder, _state(operator_feedback=None), _mock_ctx())
assert builder.human_parts == []
builder2 = PromptBuilder()
await component(
builder2,
_state(operator_feedback=["[verify failed] 'X': missing"]),
_mock_ctx(),
)
joined = "\n".join(p for p in builder2.human_parts if isinstance(p, str))
assert "Verification Findings" in joined
assert "[verify failed] 'X': missing" in joined
+235 -68
View File
@@ -14,85 +14,252 @@
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.agents.checker.checker import CheckerResult, run_async_check
from artemis.context import ArtemisContext
import pytest
from artemis.agents.checker.checker import (
CheckReport,
CheckVerdict,
_normalize_report,
assemble_checker_prompt_segments,
build_checker_tools,
build_probe_argv,
run_checkpoint_check,
run_final_check,
verdicts_allow_release,
)
from artemis.context import ArtemisContext, ExecutionSetup
from artemis.graph.checkpoints import EvidenceAnchor
from artemis.utils.plan_grammar import CheckItem
def _ci(kind="verify", when="on_complete", text="expected state", parent="p"):
return CheckItem(kind=kind, when=when, text=text, parent_key=parent)
def _mock_ctx(**setup_kwargs):
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = ExecutionSetup(**setup_kwargs)
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = "unused"
ctx.data_engine.get_agent_friendly_steps.return_value = []
ctx.data_engine.get_step_number.return_value = 7
return ctx
# --- probe_device: enumerated table, programmatic argv (§8 item 12) ------------------
def test_probe_argv_enumerated_kinds_allowed():
assert build_probe_argv("alarms") == ["dumpsys", "alarm"]
assert build_probe_argv("battery") == ["dumpsys", "battery"]
assert build_probe_argv("foreground") == ["dumpsys", "activity", "activities"]
assert build_probe_argv("packages") == ["pm", "list", "packages"]
assert build_probe_argv("setting", {"namespace": "system", "key": "screen_brightness"}) == [
"settings",
"get",
"system",
"screen_brightness",
]
assert build_probe_argv("content", {"uri": "content://settings/system"}) == [
"content",
"query",
"--uri",
"content://settings/system",
]
assert build_probe_argv("prop", {"key": "ro.build.version.sdk"}) == [
"getprop",
"ro.build.version.sdk",
]
def test_probe_argv_rejects_unknown_kind():
with pytest.raises(ValueError):
build_probe_argv("shell")
with pytest.raises(ValueError):
build_probe_argv("dumpsys")
def test_probe_argv_rejects_whitespace_and_metachars():
for bad in ("a key", "key;rm", "key|x", "key$", "key`x`", "key&&y", "a\nb"):
with pytest.raises(ValueError):
build_probe_argv("prop", {"key": bad})
with pytest.raises(ValueError):
build_probe_argv("setting", {"namespace": "system", "key": "a b"})
with pytest.raises(ValueError):
build_probe_argv("content", {"uri": "content://a; rm -rf /"})
def test_probe_argv_battery_set_inexpressible():
"""Mutating dumpsys subcommands cannot be expressed: no-parameter probes
reject every parameter."""
with pytest.raises(ValueError):
build_probe_argv("battery", {"extra": "set level 100"})
with pytest.raises(ValueError):
build_probe_argv("alarms", {"args": "anything"})
# And the setting namespace is a closed set
with pytest.raises(ValueError):
build_probe_argv("setting", {"namespace": "battery", "key": "level"})
# --- Release decision is node-side and never rewrites verdicts -----------------------
def test_verdicts_allow_release_semantics():
ok = CheckReport(
verdicts=[
CheckVerdict(item_text="a", kind="verify", status="passed", evidence="e"),
CheckVerdict(item_text="b", kind="verify", status="inconclusive", evidence="e"),
CheckVerdict(item_text="c", kind="assert", status="failed", evidence="e"),
]
)
# Assert failures never block release
assert verdicts_allow_release(ok)
blocked = CheckReport(
verdicts=[
CheckVerdict(item_text="a", kind="verify", status="failed", evidence="e"),
]
)
assert not verdicts_allow_release(blocked)
def test_normalize_report_downgrades_vague_failures_and_fills_missing():
items = [_ci(kind="verify", text="v1"), _ci(kind="assert", text="a1")]
report = CheckReport(
verdicts=[CheckVerdict(item_text="v1", kind="verify", status="failed", evidence=" ")]
)
normalized = _normalize_report(report, items)
by_text = {(v.kind, v.item_text): v for v in normalized.verdicts}
# Vague failed -> inconclusive (verdict value hygiene, not release logic)
assert by_text[("verify", "v1")].status == "inconclusive"
# Missing item gets an inconclusive verdict, never a silent pass
assert by_text[("assert", "a1")].status == "inconclusive"
# --- Assembly: prompt segments x entry x content x config (§8 item 18) ---------------
def test_prompt_segments_verify_only_has_no_assert_section():
prompts = {
"base_rules": "BASE",
"verify_semantics": "VERIFY-SEG",
"assert_semantics": "ASSERT-SEG",
"anchor_guide": "ANCHOR-SEG",
"final_guide": "FINAL-SEG",
"probe_guide": "PROBE-SEG",
}
text = assemble_checker_prompt_segments(
"checkpoint", [_ci(kind="verify")], probe_tool_registered=True, prompts=prompts
)
assert "VERIFY-SEG" in text
assert "ASSERT-SEG" not in text
assert "ANCHOR-SEG" in text
assert "FINAL-SEG" not in text
assert "PROBE-SEG" in text
text2 = assemble_checker_prompt_segments(
"final", [_ci(kind="assert")], probe_tool_registered=False, prompts=prompts
)
assert "ASSERT-SEG" in text2
assert "VERIFY-SEG" not in text2
assert "FINAL-SEG" in text2
assert "ANCHOR-SEG" not in text2
# Probe disabled -> zero mention of probing anywhere in the prompt
assert "PROBE-SEG" not in text2
def test_tool_table_probe_gate():
ctx = _mock_ctx(disable_device_probes=False)
names = {t.name for t in build_checker_tools(ctx, "checkpoint")}
assert "probe_device" in names
assert "read_note" in names
assert "get_step_detail" in names
assert "get_step_screenshot" in names
# Never any device action, note write, or sub-agent
assert not names & {
"save_note",
"update_note",
"append_note",
"click",
"swipe",
"ask_diagnoser",
"ask_explorer",
"run_adb_command",
}
ctx2 = _mock_ctx(disable_device_probes=True)
names2 = {t.name for t in build_checker_tools(ctx2, "checkpoint")}
assert "probe_device" not in names2
@pytest.mark.asyncio
async def test_run_async_check(tmp_path):
notes_dir = tmp_path / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
# Put a dummy task plan file
(notes_dir / "task_plan.md").write_text("Plan")
# Put a dummy verification_chat file
import hashlib
subgoal_text = "Open WhatsApp"
s_hash = hashlib.md5(subgoal_text.encode("utf-8")).hexdigest()
(notes_dir / f"verification_chat_{s_hash}.json").write_text("[]")
mock_ctx = MagicMock(spec=ArtemisContext)
mock_ctx.data_engine = MagicMock()
mock_ctx.data_engine.base_dir = str(tmp_path)
mock_ctx.data_engine.get_agent_friendly_steps.return_value = []
mock_ctx.execution_setup = None
# Mock controller for screen data
mock_controller = MagicMock()
mock_controller.take_screenshot = AsyncMock(return_value="dummy_screenshot_base64")
from artemis.controllers.device_controller import ScreenDataResponse
mock_screen_data = ScreenDataResponse(
base64="dummy_screenshot_base64",
elements=[],
width=1080,
height=2400,
platform="android",
async def test_checkpoint_entry_never_touches_live_screen():
"""Evidence discipline: the checkpoint entry must not capture the current
screen — its evidence is anchored history plus persistent probes."""
ctx = _mock_ctx()
report = CheckReport(
verdicts=[CheckVerdict(item_text="x", kind="verify", status="passed", evidence="e")]
)
mock_controller.get_screen_data = AsyncMock(return_value=mock_screen_data)
# Mock LLM response with with_structured_output
mock_llm = MagicMock()
mock_response = MagicMock()
mock_response.content = "Task completed successfully."
mock_response.tool_calls = []
async def mock_astream(*args, **kwargs):
yield mock_response
mock_llm.astream.side_effect = mock_astream
mock_llm.bind_tools.return_value = mock_llm
mock_structured_llm = MagicMock()
mock_structured_llm.ainvoke = AsyncMock(
return_value=CheckerResult(success=True, reason="Task completed successfully.")
)
mock_llm.with_structured_output.return_value = mock_structured_llm
structured = MagicMock()
structured.ainvoke = AsyncMock(return_value=report)
llm = MagicMock()
llm.with_structured_output.return_value = structured
response = MagicMock()
response.tool_calls = []
with (
patch("artemis.agents.checker.checker.get_llm", return_value=llm),
patch(
"artemis.agents.checker.checker.UnifiedMobileController",
return_value=mock_controller,
"artemis.agents.checker.checker.acomplete",
new=AsyncMock(return_value=response),
),
patch("artemis.agents.checker.checker.get_llm", return_value=mock_llm),
patch(
"artemis.utils.task_tree.build_plan_and_history",
return_value="Task tree",
),
"artemis.agents.checker.checker._capture_final_screen",
new=AsyncMock(return_value=(None, "SHOULD NOT BE CALLED")),
) as capture_mock,
):
result = await run_async_check(
mock_ctx,
subgoal_text=subgoal_text,
subgoal_hash=s_hash,
raw_perception_data={"screenshot_b64": "dummy", "width": 1080, "height": 2400},
latest_ui_hierarchy=[],
result = await run_checkpoint_check(
ctx,
check_items=[_ci(text="x")],
anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text="- [x] G"),
goal="the goal",
subgoal_text="G",
)
assert result["status"] == "success"
assert result["reason"] == "Task completed successfully."
assert mock_llm.astream.called
assert mock_llm.with_structured_output.called
capture_mock.assert_not_awaited()
assert result.verdicts[0].status == "passed"
@pytest.mark.asyncio
async def test_final_entry_captures_live_screen():
ctx = _mock_ctx()
report = CheckReport(verdicts=[])
structured = MagicMock()
structured.ainvoke = AsyncMock(return_value=report)
llm = MagicMock()
llm.with_structured_output.return_value = structured
response = MagicMock()
response.tool_calls = []
with (
patch("artemis.agents.checker.checker.get_llm", return_value=llm),
patch(
"artemis.agents.checker.checker.acomplete",
new=AsyncMock(return_value=response),
),
patch(
"artemis.agents.checker.checker._capture_final_screen",
new=AsyncMock(return_value=("b64img", "elements")),
) as capture_mock,
):
await run_final_check(
ctx,
goal="the goal",
plan_text="- [x] G",
ledger=[],
check_items=[_ci(kind="assert", when="at_end", text="no crash", parent=None)],
)
capture_mock.assert_awaited_once()
+2 -15
View File
@@ -14,9 +14,7 @@
from unittest.mock import AsyncMock, Mock, patch
from langchain_core.messages import AIMessage, ToolMessage
from langgraph.types import Command
from artemis.agents.diagnoser.diagnoser import Diagnoser
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.graph.state import State
import pytest
@@ -148,14 +146,9 @@ async def test_diagnostic_agent_tool_calls(
"purpose": None,
"state": None,
}
mock_command = Command(
update={
VALIDATOR_MESSAGES_KEY: [
ToolMessage(content="Video shows UI loading.", tool_call_id="call_2")
]
}
mock_video_tool.ainvoke.return_value = ToolMessage(
content="Video shows UI loading.", tool_call_id="call_2"
)
mock_video_tool.ainvoke.return_value = mock_command
mock_get_video_tool.return_value = mock_video_tool
agent = Diagnoser(mock_context)
@@ -273,12 +266,6 @@ async def test_diagnoser_background_job_lifecycle(
mock_llm.astream.side_effect = mock_astream
mock_get_llm.return_value = mock_llm
# Mock asanitize_update on mock_state
async def mock_asanitize_update(ctx, update, agent):
return update
mock_state.asanitize_update = mock_asanitize_update
# Instantiate the Diagnoser
agent = Diagnoser(ctx)
+31 -167
View File
@@ -30,10 +30,7 @@ async def test_operator_node_fast_path():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_state.subgoal_plan = []
mock_state.initial_goal = "Test goal"
mock_state.operator_tactical_plan = []
# Mock controller and data
mock_controller = MagicMock()
@@ -62,18 +59,15 @@ async def test_operator_node_fast_path():
# Patch dependencies
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx)
await node(mock_state)
node_update = await node(mock_state)
# Verify state was updated with action
mock_state.asanitize_update.assert_called_once()
args, kwargs = mock_state.asanitize_update.call_args
update_dict = kwargs["update"]
update_dict = node_update
assert "structured_decisions" in update_dict
decisions = json.loads(update_dict["structured_decisions"])
assert decisions[0]["action"] == "tap"
assert decisions[0]["coordinates"] == [54, 120]
assert update_dict["complete_subgoals_by_ids"] == []
@pytest.mark.asyncio
@@ -241,8 +235,6 @@ async def test_operator_node_multiple_actions():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_state.subgoal_plan = []
mock_state.initial_goal = "Test goal"
mock_controller = MagicMock()
@@ -267,11 +259,9 @@ async def test_operator_node_multiple_actions():
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx)
await node(mock_state)
node_update = await node(mock_state)
mock_state.asanitize_update.assert_called_once()
args, kwargs = mock_state.asanitize_update.call_args
update_dict = kwargs["update"]
update_dict = node_update
assert "structured_decisions" in update_dict
decisions = json.loads(update_dict["structured_decisions"])
@@ -296,7 +286,6 @@ async def test_operator_node_no_record_step():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_state.initial_goal = "Test goal"
mock_controller = MagicMock()
@@ -314,13 +303,11 @@ async def test_operator_node_no_record_step():
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx)
await node(mock_state)
node_update = await node(mock_state)
mock_ctx.data_engine.record_step.assert_not_called()
mock_state.asanitize_update.assert_called_once()
args, kwargs = mock_state.asanitize_update.call_args
update_dict = kwargs["update"]
update_dict = node_update
assert "structured_decisions" in update_dict
@@ -336,7 +323,6 @@ async def test_operator_dynamic_prompt():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.initial_goal = "Test goal"
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
class MockComponent(PromptComponent):
async def __call__(self, builder, state, ctx, **kwargs):
@@ -367,7 +353,7 @@ async def test_operator_dynamic_prompt():
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx, prompt_components=[MockComponent()])
await node(mock_state)
node_update = await node(mock_state)
@pytest.mark.asyncio
@@ -443,7 +429,6 @@ async def test_operator_history_compression():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.initial_goal = "Test goal"
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_controller = MagicMock()
mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=")
@@ -465,10 +450,6 @@ async def test_operator_history_compression():
"Goals: {{ initial_goal }}\nPlan:\n{{ plan_and_history"
" }}\n# CURRENT OBSERVATION"
),
"troubleshooter_template": (
"Goals: {{ initial_goal }}\nPlan:\n{{ plan_and_history"
" }}\n# CURRENT OBSERVATION"
),
}
)
elif "task_plan.md" in path_str:
@@ -508,7 +489,7 @@ async def test_operator_history_compression():
patch("pathlib.Path.read_text", mock_read_text),
):
node = OperatorNode(mock_ctx, last_n_detailed=5)
await node(mock_state)
node_update = await node(mock_state)
@pytest.mark.asyncio
@@ -528,7 +509,7 @@ async def test_operator_loop_detection():
node = OperatorNode(mock_ctx)
with pytest.raises(RuntimeError) as exc_info:
await node(mock_state)
node_update = await node(mock_state)
assert "Infinite loop detected" in str(exc_info.value)
@@ -546,7 +527,6 @@ async def test_operator_tracks_subagent_calls():
mock_state = MagicMock()
mock_state.subagent_calls = ["ask_explorer"]
mock_state.initial_goal = "Test goal"
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_controller = MagicMock()
mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=")
@@ -589,11 +569,9 @@ async def test_operator_tracks_subagent_calls():
),
):
node = OperatorNode(mock_ctx, tools=[mock_diagnoser_tool])
await node(mock_state)
node_update = await node(mock_state)
mock_state.asanitize_update.assert_called_once()
args, kwargs = mock_state.asanitize_update.call_args
update_dict = kwargs["update"]
update_dict = node_update
assert "subagent_calls" in update_dict
assert update_dict["subagent_calls"] == [
@@ -615,7 +593,6 @@ async def test_operator_defer_action_for_gathering():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_state.initial_goal = "Test goal"
mock_controller = MagicMock()
@@ -684,15 +661,13 @@ async def test_operator_defer_action_for_gathering():
),
):
node = OperatorNode(mock_ctx, tools=[mock_diagnoser_tool])
await node(mock_state)
node_update = await node(mock_state)
# Subagent tool should have been executed
mock_call_receiver.assert_called_once_with(query="check something", state=mock_state)
# Verify final state has the action execution from the second response
mock_state.asanitize_update.assert_called_once()
args, kwargs = mock_state.asanitize_update.call_args
update_dict = kwargs["update"]
update_dict = node_update
assert "structured_decisions" in update_dict
decisions = json.loads(update_dict["structured_decisions"])
@@ -701,100 +676,6 @@ async def test_operator_defer_action_for_gathering():
assert decisions[0]["coordinates"] == [54, 120]
@pytest.mark.asyncio
async def test_operator_defer_reply_to_checker_for_gathering():
from artemis.agents.operator.operator import OperatorNode
from artemis.context import ArtemisContext
from unittest.mock import AsyncMock, MagicMock, patch
mock_ctx = MagicMock(spec=ArtemisContext)
mock_ctx.execution_setup = None
from pathlib import Path
mock_ctx.data_engine = MagicMock()
mock_ctx.data_engine.base_dir = Path("/tmp/test_session")
mock_ctx.data_engine.get_agent_friendly_steps.return_value = []
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_state.initial_goal = "Test goal"
mock_llm = MagicMock()
# First response calls ask_diagnoser and reply_to_checker
mock_response_1 = MagicMock()
mock_response_1.tool_calls = [
{"name": "ask_diagnoser", "args": {"query": "test"}, "id": "call_diag"},
{
"name": "reply_to_checker",
"args": {"reasoning": "I solved it"},
"id": "call_reply",
},
]
# Second response stops without action tools
mock_response_2 = MagicMock()
mock_response_2.tool_calls = []
responses = [mock_response_1, mock_response_2]
call_count = 0
async def mock_ainvoke(*args, **kwargs):
nonlocal call_count
if call_count < len(responses):
res = responses[call_count]
call_count += 1
return res
mock_llm.ainvoke.side_effect = mock_ainvoke
mock_llm.bind_tools.return_value = mock_llm
from langgraph.prebuilt import InjectedState
from typing import Annotated
mock_diagnoser_tool = MagicMock()
mock_diagnoser_tool.name = "ask_diagnoser"
mock_diagnoser_tool.args = {"state": None}
mock_call_receiver = Mock()
async def dummy_diagnoser_func(query: str, state: Annotated[MagicMock, InjectedState]):
mock_call_receiver(query=query, state=state)
return "Diagnostics completed"
mock_diagnoser_tool.coroutine = dummy_diagnoser_func
mock_diagnoser_tool.func = None
with (
patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm),
patch(
"artemis.agents.operator.operator.trace_langchain_tool",
side_effect=lambda t, ctx: t,
),
patch(
"artemis.agents.operator.operator.OperatorNode._has_checker_feedback",
return_value=True,
),
patch(
"artemis.agents.operator.operator.OperatorNode._get_verification_chat_rounds",
return_value=(1, 1),
),
):
node = OperatorNode(mock_ctx, tools=[mock_diagnoser_tool])
await node(mock_state)
# Subagent was called
mock_call_receiver.assert_called_once()
# Verify reply to checker was NOT executed / flagged
mock_state.asanitize_update.assert_called_once()
args, kwargs = mock_state.asanitize_update.call_args
update_dict = kwargs["update"]
assert update_dict.get("operator_replied") is False
@pytest.mark.asyncio
async def test_wait_actions_translation():
from artemis.agents.operator.operator import OperatorNode
@@ -907,7 +788,6 @@ async def test_operator_no_defer_for_note_updating():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_state.initial_goal = "Test goal"
mock_controller = MagicMock()
@@ -965,15 +845,13 @@ async def test_operator_no_defer_for_note_updating():
),
):
node = OperatorNode(mock_ctx, tools=[mock_update_note_tool])
await node(mock_state)
node_update = await node(mock_state)
# The update_note tool should have been executed immediately
mock_call_receiver.assert_called_once_with(key="progress", content="step 1")
# The click action should NOT be deferred, so it should be in structured_decisions
mock_state.asanitize_update.assert_called_once()
args, kwargs = mock_state.asanitize_update.call_args
update_dict = kwargs["update"]
update_dict = node_update
assert "structured_decisions" in update_dict
decisions = json.loads(update_dict["structured_decisions"])
@@ -995,7 +873,6 @@ async def test_operator_defer_for_note_reading():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_state.initial_goal = "Test goal"
mock_controller = MagicMock()
@@ -1058,7 +935,7 @@ async def test_operator_defer_for_note_reading():
),
):
node = OperatorNode(mock_ctx, tools=[mock_read_note_tool])
await node(mock_state)
node_update = await node(mock_state)
# The read_note tool should have been executed
mock_call_receiver.assert_called_once_with(key="progress")
@@ -1068,9 +945,7 @@ async def test_operator_defer_for_note_reading():
# Total call_count should be 2.
assert call_count == 2
mock_state.asanitize_update.assert_called_once()
args, kwargs = mock_state.asanitize_update.call_args
update_dict = kwargs["update"]
update_dict = node_update
assert "structured_decisions" in update_dict
decisions = json.loads(update_dict["structured_decisions"])
@@ -1092,7 +967,6 @@ async def test_operator_no_defer_for_note_appending():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_state.initial_goal = "Test goal"
mock_controller = MagicMock()
@@ -1149,15 +1023,13 @@ async def test_operator_no_defer_for_note_appending():
),
):
node = OperatorNode(mock_ctx, tools=[mock_append_note_tool])
await node(mock_state)
node_update = await node(mock_state)
# The append_note tool should have been executed immediately
mock_call_receiver.assert_called_once_with(key="progress", content="step 1")
# The click action should NOT be deferred, so it should be in structured_decisions
mock_state.asanitize_update.assert_called_once()
args, kwargs = mock_state.asanitize_update.call_args
update_dict = kwargs["update"]
update_dict = node_update
assert "structured_decisions" in update_dict
decisions = json.loads(update_dict["structured_decisions"])
@@ -1180,7 +1052,6 @@ async def test_operator_background_tasks_prompt_injection():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.initial_goal = "Test goal"
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_controller = MagicMock()
mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=")
@@ -1231,7 +1102,7 @@ async def test_operator_background_tasks_prompt_injection():
active_background_tasks=active_background_tasks,
)
captured_messages.extend(messages)
return [], False, None, None, False
return [], None, None, False
with (
patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm),
@@ -1241,7 +1112,7 @@ async def test_operator_background_tasks_prompt_injection():
),
):
node = OperatorNode(mock_ctx)
await node(mock_state)
node_update = await node(mock_state)
# Assert that the captured messages (prompts) contain the background tasks section
full_content = ""
@@ -1276,7 +1147,6 @@ async def test_operator_task_plan_warning_prompt_injection():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.initial_goal = "Test goal"
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_controller = MagicMock()
mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=")
@@ -1323,7 +1193,7 @@ async def test_operator_task_plan_warning_prompt_injection():
task_plan="",
)
captured_messages_1.extend(messages)
return [], False, None, None, False
return [], None, None, False
with (
patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm),
@@ -1333,7 +1203,7 @@ async def test_operator_task_plan_warning_prompt_injection():
),
):
node = OperatorNode(mock_ctx)
await node(mock_state)
node_update = await node(mock_state)
full_content_1 = ""
for m in captured_messages_1:
@@ -1376,7 +1246,7 @@ async def test_operator_task_plan_warning_prompt_injection():
task_plan="",
)
captured_messages_2.extend(messages)
return [], False, None, None, False
return [], None, None, False
with (
patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm),
@@ -1386,7 +1256,7 @@ async def test_operator_task_plan_warning_prompt_injection():
),
):
node = OperatorNode(mock_ctx)
await node(mock_state)
node_update = await node(mock_state)
full_content_2 = ""
for m in captured_messages_2:
@@ -1418,7 +1288,6 @@ async def test_operator_tool_limit_exceeded_warning():
# Set the flag to True to simulate tool call limit exceeded in previous turn
mock_state.operator_tool_limit_exceeded = True
mock_state.initial_goal = "Test goal"
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_controller = MagicMock()
mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=")
@@ -1443,8 +1312,8 @@ async def test_operator_tool_limit_exceeded_warning():
task_plan="",
)
captured_messages.extend(messages)
# Return 5 elements: action_result, requested_argue, raw_thinking, native_thinking, tool_limit_exceeded
return [], False, None, None, False
# Return 4 elements: action_result, raw_thinking, native_thinking, tool_limit_exceeded
return [], None, None, False
with (
patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm),
@@ -1454,7 +1323,7 @@ async def test_operator_tool_limit_exceeded_warning():
),
):
node = OperatorNode(mock_ctx)
await node(mock_state)
node_update = await node(mock_state)
full_content = ""
for m in captured_messages:
@@ -1469,9 +1338,7 @@ async def test_operator_tool_limit_exceeded_warning():
"You did not execute any screen interaction actions in your last turn" in full_content
)
mock_state.asanitize_update.assert_called_once()
_, kwargs = mock_state.asanitize_update.call_args
assert kwargs["update"].get("operator_tool_limit_exceeded") is False
assert node_update.get("operator_tool_limit_exceeded") is False
@pytest.mark.asyncio
@@ -1488,7 +1355,6 @@ async def test_operator_fallback_function_call_parsing():
mock_state = MagicMock()
mock_state.subagent_calls = []
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_state.initial_goal = "Test goal"
mock_controller = MagicMock()
@@ -1515,12 +1381,10 @@ async def test_operator_fallback_function_call_parsing():
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx)
await node(mock_state)
node_update = await node(mock_state)
# Verify state was updated with parsed click action
mock_state.asanitize_update.assert_called_once()
_, kwargs = mock_state.asanitize_update.call_args
update_dict = kwargs["update"]
update_dict = node_update
assert "structured_decisions" in update_dict
decisions = json.loads(update_dict["structured_decisions"])
+8 -9
View File
@@ -29,15 +29,14 @@ def test_operator_prompt_contract_matches_note_runtime_semantics():
assert "memory note tools (`read_note`, `list_notes`, `save_note`, `update_note`" not in prompt
def test_troubleshooter_prompt_has_mutually_exclusive_recovery_branches():
prompt = apply_operator_prompt_contract(load_operator_prompts()["troubleshooter_template"])
assert "invoke `reply_to_checker` directly" in prompt
assert "This branch takes precedence over diagnosis" in prompt
assert "perform that correction directly" in prompt
assert "also takes precedence over generic diagnosis triggers" in prompt
assert "A Checker rejection alone is not a mandatory diagnosis trigger" in prompt
assert "**Validation/Checker Rejection**" not in prompt
def test_operator_prompt_is_single_template_without_checker_dialogue():
"""The prompt is never switched by verification results: main_template is
the only template, and no checker-dialogue machinery is advertised."""
prompts = load_operator_prompts()
assert set(prompts) == {"main_template"}
prompt = apply_operator_prompt_contract(prompts["main_template"])
assert "reply_to_checker" not in prompt
assert "Checker" not in prompt
def test_operator_prompt_omits_environment_trust_and_explorer_directives():
@@ -12,10 +12,25 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import MagicMock
from artemis.graph.graph import check_plan_mutation_rejections
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from artemis.context import ArtemisContext, ExecutionSetup
from artemis.graph.graph import (
check_plan_mutation_rejections,
check_unintended_rewrite,
wrap_note_tool,
)
from artemis.graph.state import State
CONTINUOUS_PLAN = (
"- [x] Open app\n"
"- [/] [Loop:continuous] Periodically monitor for new emails "
"(Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
)
def test_check_plan_mutation_incomplete_nested():
content_before = "- [/] Main task\n - [ ] Subtask 1"
@@ -26,57 +41,52 @@ def test_check_plan_mutation_incomplete_nested():
def test_check_plan_mutation_delete_continuous_loop():
content_before = (
"- [x] Open app\n"
"- [/] [Loop] Periodically monitor for new emails "
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
)
content_after = (
"- [x] Open app\n"
"- [x] Monitor for new emails\n"
" - [x] Baseline established\n"
" - [x] Polling Check #1: Done"
)
res = check_plan_mutation_rejections(content_before, content_after)
res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after)
assert res is not None
assert "cannot delete an active [Loop]" in res
assert "cannot delete an active [Loop:continuous]" in res
def test_check_plan_mutation_mark_continuous_loop_completed():
content_before = (
"- [x] Open app\n"
"- [/] [Loop] Periodically monitor for new emails "
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
)
content_after = (
"- [x] Open app\n"
"- [x] [Loop] Periodically monitor for new emails "
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
)
res = check_plan_mutation_rejections(content_before, content_after)
content_after = CONTINUOUS_PLAN.replace("- [/] [Loop:continuous]", "- [x] [Loop:continuous]")
res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after)
assert res is not None
assert "cannot be unilaterally marked as completed [x]" in res
assert "cannot be" in res and "marked as completed [x]" in res
def test_check_plan_mutation_user_stopped_allowed():
content_after = CONTINUOUS_PLAN.replace("- [/] [Loop:continuous]", "- [x] [Loop:continuous]")
state = MagicMock(spec=State)
state.user_stop_requested = True
res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after, state=state)
assert res is None
def test_check_plan_mutation_stop_wording_is_not_a_signal():
"""Natural-language 'stop' phrasing must NOT unlock the continuous loop —
only the explicit release_loop signal (user_stop_requested) does."""
content_after = CONTINUOUS_PLAN.replace("- [/] [Loop:continuous]", "- [x] [Loop:continuous]")
state = MagicMock(spec=State)
state.user_stop_requested = False
state.injected_instruction = "Please stop the task now."
res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after, state=state)
assert res is not None
def test_check_plan_mutation_bounded_loop_completion_allowed():
"""Plain [Loop] milestones are bounded: the model may complete them."""
content_before = (
"- [x] Open app\n"
"- [/] [Loop] Periodically monitor for new emails "
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
"- [/] [Loop] Inspect candidate emails (Exit: all 5 candidates inspected)\n"
" - [x] Candidate #5: Done"
)
content_after = (
"- [x] Open app\n"
"- [x] [Loop] Periodically monitor for new emails "
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
)
state = MagicMock(spec=State)
state.injected_instruction = "Please stop the task now."
res = check_plan_mutation_rejections(content_before, content_after, state=state)
content_after = content_before.replace("- [/] [Loop]", "- [x] [Loop]")
res = check_plan_mutation_rejections(content_before, content_after)
assert res is None
@@ -85,3 +95,209 @@ def test_check_plan_mutation_normal_bounded_task():
content_after = "- [x] Open Settings\n- [x] Toggle WiFi"
res = check_plan_mutation_rejections(content_before, content_after)
assert res is None
def test_check_unintended_rewrite_detects_hand_slip():
content_before = "- [x] Open Settings app\n- [/] Toggle the WiFi switch"
# Full rewrite: milestone 1's status unchanged but text drifted
content_after = "- [x] Open the Settings application\n- [/] Toggle the WiFi switch"
res = check_unintended_rewrite(content_before, content_after)
assert res is not None
assert "Open Settings app" in res
assert "update_note" in res
def test_check_unintended_rewrite_allows_status_flips_and_appends():
content_before = "- [x] Open Settings\n- [/] Toggle WiFi"
content_after = "- [x] Open Settings\n- [x] Toggle WiFi\n - [x] Confirmed enabled"
assert check_unintended_rewrite(content_before, content_after) is None
def test_check_unintended_rewrite_ignores_structural_replans():
# Milestone count changes are declared replans, judged by planner validation
content_before = "- [x] Open Settings\n- [/] Toggle WiFi"
content_after = "- [x] Open Settings\n- [/] Toggle WiFi\n- [ ] Verify connectivity"
assert check_unintended_rewrite(content_before, content_after) is None
# --- §4.3 deterministic check-line guard (independent of planner validation) ---------
GUARDED_PLAN = (
"- [/] Create the alarm\n"
" - verify: alarm exists in the list\n"
" - assert: a toast appeared\n"
"- [ ] Next milestone\n"
)
def _guard_ctx(tmp_path, **setup_kwargs):
# Planner validation OFF by default: the guard must hold on its own.
setup_kwargs.setdefault("disable_planner_validation", True)
setup_kwargs.setdefault("disable_checker", False)
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = ExecutionSetup(**setup_kwargs)
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = str(tmp_path)
ctx.planner_task = None
ctx.last_validated_plan = None
ctx.pending_validated_plan = None
ctx.task_plan_content_before = None
ctx.pending_checkpoints = []
ctx.checkpoint_tasks = {}
return ctx
def _guard_plan_path(tmp_path, content=GUARDED_PLAN):
notes_dir = tmp_path / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
path = notes_dir / "task_plan.md"
path.write_text(content, encoding="utf-8")
return path
def _save_invoke(path):
async def fake(tool, args, tool_call_id, state, record_trace=None):
path.write_text(args["content"], encoding="utf-8")
return "Success"
return fake
def _save_tool():
tool = MagicMock()
tool.name = "save_note"
tool.description = "save_note tool"
return tool
@pytest.mark.asyncio
async def test_operator_deleting_check_line_is_restored(tmp_path):
path = _guard_plan_path(tmp_path)
ctx = _guard_ctx(tmp_path)
rewritten = "- [/] Create the alarm\n - assert: a toast appeared\n- [ ] Next milestone\n"
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=_save_invoke(path),
):
wrapped = wrap_note_tool(ctx, _save_tool())
await wrapped.ainvoke({"key": "task_plan", "content": rewritten, "tool_call_id": "t"})
content = path.read_text(encoding="utf-8")
assert "- verify: alarm exists in the list" in content
assert "- assert: a toast appeared" in content
# No planner validation was needed or spawned (guard is deterministic)
assert ctx.planner_task is None
@pytest.mark.asyncio
async def test_operator_rewording_check_line_restores_original_keeps_new(tmp_path):
path = _guard_plan_path(tmp_path)
ctx = _guard_ctx(tmp_path)
rewritten = GUARDED_PLAN.replace(
" - verify: alarm exists in the list\n",
" - verify: alarm roughly configured\n",
)
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=_save_invoke(path),
):
wrapped = wrap_note_tool(ctx, _save_tool())
await wrapped.ainvoke({"key": "task_plan", "content": rewritten, "tool_call_id": "t"})
content = path.read_text(encoding="utf-8")
# Original standard restored; the Operator's new line survives as an addition
assert "- verify: alarm exists in the list" in content
assert "- verify: alarm roughly configured" in content
@pytest.mark.asyncio
async def test_deleted_parent_turns_check_into_task_level_at_end(tmp_path):
path = _guard_plan_path(tmp_path)
ctx = _guard_ctx(tmp_path)
# Whole parent subgoal removed
rewritten = "- [ ] Next milestone\n"
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=_save_invoke(path),
):
wrapped = wrap_note_tool(ctx, _save_tool())
await wrapped.ainvoke({"key": "task_plan", "content": rewritten, "tool_call_id": "t"})
content = path.read_text(encoding="utf-8")
assert "- verify@end: alarm exists in the list" in content
assert "- assert@end: a toast appeared" in content
@pytest.mark.asyncio
async def test_operator_adding_check_lines_is_allowed(tmp_path):
path = _guard_plan_path(tmp_path)
ctx = _guard_ctx(tmp_path)
extended = GUARDED_PLAN + " - verify: extra criterion\n"
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=_save_invoke(path),
):
wrapped = wrap_note_tool(ctx, _save_tool())
result = await wrapped.ainvoke(
{"key": "task_plan", "content": extended, "tool_call_id": "t"}
)
assert result == "Success"
assert "- verify: extra criterion" in path.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_guard_holds_with_planner_validation_disabled_and_checks_off(tmp_path):
"""The guard is content-driven: it protects check lines even when every
check switch AND planner validation are disabled."""
path = _guard_plan_path(tmp_path)
ctx = _guard_ctx(tmp_path, disable_checker=True, disable_planner_validation=True)
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=_save_invoke(path),
):
wrapped = wrap_note_tool(ctx, _save_tool())
await wrapped.ainvoke(
{
"key": "task_plan",
"content": "- [/] Create the alarm\n- [ ] Next milestone\n",
"tool_call_id": "t",
}
)
content = path.read_text(encoding="utf-8")
assert "- verify: alarm exists in the list" in content
assert "- assert: a toast appeared" in content
@pytest.mark.asyncio
async def test_planner_node_writes_are_not_guarded():
"""The Planner keeps revision authority over check standards: its node is
wired with the UNWRAPPED note tools, so the guard never applies to it."""
from artemis.context import DeviceContext, DevicePlatform
from artemis.graph.graph import get_graph
device = DeviceContext(
host_platform="LINUX",
mobile_platform=DevicePlatform.ANDROID,
device_id="dummy",
device_width=1080,
device_height=2400,
)
ctx = ArtemisContext(device=device, execution_setup=ExecutionSetup())
graph = await get_graph(ctx)
planner_tools = {t.name: t for t in graph.nodes["planner"].bound.afunc.tools}
operator_tools = {t.name: t for t in graph.nodes["operator"].bound.afunc.tools}
# Operator's plan-writing tools go through the guard wrapper...
assert operator_tools["save_note"].coroutine.__name__ == "wrapped_note_tool"
assert operator_tools["update_note"].coroutine.__name__ == "wrapped_update_note"
# ...the Planner's do not.
assert getattr(planner_tools["save_note"].coroutine, "__name__", "") != "wrapped_note_tool"
assert getattr(planner_tools["update_note"].coroutine, "__name__", "") != "wrapped_update_note"
-3
View File
@@ -32,9 +32,6 @@ class DummyState:
self.operator_raw_data = operator_raw_data
self.operator_replan_reason = operator_replan_reason
async def asanitize_update(self, ctx, update, agent):
return update
@pytest.fixture
def mock_context():
-112
View File
@@ -1,112 +0,0 @@
# 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.
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.agents.operator.operator import OperatorNode
from artemis.context import ArtemisContext
from artemis.graph.graph import execution_check_node
from artemis.graph.state import State
import pytest
@pytest.mark.asyncio
async def test_snapshot_rollback_flow(tmp_path):
# Setup dummy directories
base_dir = tmp_path / "trace"
notes_dir = base_dir / "notes"
notes_dir.mkdir(parents=True)
task_plan_path = notes_dir / "task_plan.md"
task_plan_path.write_text("Initial Plan", encoding="utf-8")
# Mock Context
mock_ctx = MagicMock(spec=ArtemisContext)
mock_ctx.data_engine = MagicMock()
mock_ctx.data_engine.base_dir = base_dir
mock_ctx.execution_setup = None
# Mock State
mock_state = MagicMock(spec=State)
mock_state.subagent_calls = []
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
mock_state.initial_goal = "Test goal"
mock_state.short_term_memory = ""
mock_state.operator_raw_data = {
"screenshot_b64": "YmFzZTY0",
"xml_hierarchy": "<hierarchy></hierarchy>",
"ocr_results": [],
}
mock_state.current_step_id = "test-step-id"
# Mock Controller and LLM to let Operator run minimally
mock_controller = MagicMock()
mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0")
mock_device_data = MagicMock()
mock_device_data.elements = []
mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data)
mock_llm = MagicMock()
mock_response = MagicMock()
mock_response.tool_calls = []
async def mock_astream(*args, **kwargs):
yield mock_response
mock_llm.astream.side_effect = mock_astream
mock_llm.bind_tools.return_value = mock_llm
# 1. Run Operator to trigger snapshot
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx)
await node(mock_state)
# Verify snapshot was created
snapshot_dir = notes_dir.with_name("notes_snapshot")
assert snapshot_dir.exists()
assert (snapshot_dir / "task_plan.md").read_text(encoding="utf-8") == "Initial Plan"
assert mock_ctx.task_plan_snapshot == snapshot_dir
# 2. Simulate Operator optimistic modification
task_plan_path.write_text("Modified Plan", encoding="utf-8")
# 3. Run execution_check_node with failure to trigger rollback
future = asyncio.Future()
future.set_result({"status": "failed"})
mock_ctx.checker_task = future
await execution_check_node(mock_state, mock_ctx)
# Verify rollback restored the file
assert task_plan_path.read_text(encoding="utf-8") == "Initial Plan"
# 4. Run Operator again to create new snapshot (simulate next attempt)
# First modify it again to simulate operator working on restored plan
task_plan_path.write_text("Modified Plan 2", encoding="utf-8")
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
await node(mock_state)
assert (snapshot_dir / "task_plan.md").read_text(encoding="utf-8") == "Modified Plan 2"
# 5. Run execution_check_node with success to trigger commit (cleanup)
future_success = asyncio.Future()
future_success.set_result({"status": "success"})
mock_ctx.checker_task = future_success
await execution_check_node(mock_state, mock_ctx)
# Verify snapshot was deleted
assert not snapshot_dir.exists()
+1 -5
View File
@@ -37,18 +37,13 @@ class DummyState:
def __init__(
self,
structured_decisions,
validator_messages=[],
current_step_id=None,
latest_screenshot=None,
):
self.structured_decisions = structured_decisions
self.validator_messages = validator_messages
self.current_step_id = current_step_id
self.latest_screenshot = latest_screenshot
async def asanitize_update(self, ctx, update, agent):
return update
class FakeActionSession:
"""Test double for artemis.mcp.action_session.ActionSession."""
@@ -146,6 +141,7 @@ async def test_validator_success(mock_mcp, mock_context, temp_screenshot):
# entirely. Its hazard class (leaked AnyIO cancel scopes) is now pinned by
# tests/unit/mcp/test_action_session.py against the in-process ActionSession.
@pytest.mark.asyncio
async def test_validator_failure_analysis(mock_mcp, mock_context, temp_screenshot):
"""Test that ValidatorNode triggers failure analysis on error and handles
@@ -35,7 +35,6 @@ def mock_artemis_ctx(tmp_path):
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
mock_ctx.task_plan_snapshot = None
return mock_ctx
@@ -225,14 +224,9 @@ async def test_perception_node_async_offloading(mock_artemis_ctx, tmp_path):
mock_ocr.return_value = [{"text": "Home", "box": [100, 100, 200, 200]}]
state = State(
messages=[HumanMessage(content="test")],
initial_goal="test goal",
complete_subgoals_by_ids=[],
validator_messages=[],
latest_ui_hierarchy=[],
latest_screenshot=None,
focused_app_info=None,
device_date=None,
structured_decisions=None,
)
@@ -258,42 +252,6 @@ async def test_perception_node_async_offloading(mock_artemis_ctx, tmp_path):
assert engine.get_image_path(expected_hash).exists()
@pytest.mark.asyncio
async def test_snapshot_copy_and_restore_offloading(mock_artemis_ctx, tmp_path):
"""Verify operator create_snapshot and graph rollback/restore execute asynchronously without blocking."""
from artemis.utils.file import create_snapshot, restore_snapshot
notes_dir = tmp_path / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
(notes_dir / "task_plan.md").write_text("# Plan\n- Step 1", encoding="utf-8")
(notes_dir / "verification_chat_1.json").write_text('{"messages": ["hello"]}', encoding="utf-8")
snapshot_dir = tmp_path / "notes_snapshot"
# Test async offloading of create_snapshot (operator pattern)
await asyncio.to_thread(create_snapshot, notes_dir, snapshot_dir)
assert snapshot_dir.exists()
assert (snapshot_dir / "task_plan.md").read_text(encoding="utf-8") == "# Plan\n- Step 1"
# Modify live task_plan.md
(notes_dir / "task_plan.md").write_text("# Plan\n- Step 1 (Modified)", encoding="utf-8")
# Test async offloading of restore_snapshot (graph pattern when checker fails)
def _restore_and_preserve():
chat_contents = {}
for p in notes_dir.glob("verification_chat_*.json"):
chat_contents[p.name] = p.read_text(encoding="utf-8")
restore_snapshot(snapshot_dir, notes_dir)
for name, content in chat_contents.items():
(notes_dir / name).write_text(content, encoding="utf-8")
await asyncio.to_thread(_restore_and_preserve)
assert (notes_dir / "task_plan.md").read_text(encoding="utf-8") == "# Plan\n- Step 1"
assert (notes_dir / "verification_chat_1.json").read_text(
encoding="utf-8"
) == '{"messages": ["hello"]}'
@pytest.mark.asyncio
async def test_ocr_api_persistent_http_client_singleton_and_tls_reuse():
"""Verify get_http_client returns a persistent singleton httpx.AsyncClient that reuses TLS connections across OCR calls."""
@@ -411,15 +369,10 @@ async def test_validator_pre_execution_loop_reverted_to_exact_safety_contract(
]
)
state = State(
messages=[],
initial_goal="test",
complete_subgoals_by_ids=[],
validator_messages=[],
latest_ui_hierarchy=[],
latest_screenshot="dummy.png",
structured_decisions=decisions,
focused_app_info=None,
device_date=None,
)
with (
+545
View File
@@ -0,0 +1,545 @@
# 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.
"""Checkpoint scheduling and harvest tests: queue/spawn separation, supersede
without verdict loss, applicability gating, repair quota, halt policy,
timeouts, unconditional record_step, and fail-open axis separation."""
import asyncio
import base64
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from artemis.agents.checker.checker import CheckReport, CheckVerdict
from artemis.context import ArtemisContext, ExecutionSetup
from artemis.graph.checkpoints import (
CheckpointRun,
PendingCheckpoint,
harvest_finished_checkpoints,
harvest_run,
queue_checkpoints,
read_ledger,
spawn_pending_checkpoints,
)
from artemis.graph.graph import execution_check_node, wrap_note_tool
from artemis.graph.state import State
from artemis.utils.plan_grammar import CheckItem, parse_plan, subgoal_hash
GOAL_TEXT = "Create the alarm"
GOAL_KEY = subgoal_hash(GOAL_TEXT)
PLAN_WITH_CHECKS = (
f"- [x] {GOAL_TEXT}\n"
" - verify: the alarm list shows 7:30 AM\n"
" - assert: a toast appeared\n"
"- [ ] Next milestone\n"
)
def _make_ctx(tmp_path, **setup_kwargs):
setup_kwargs.setdefault("disable_checker", False)
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = ExecutionSetup(**setup_kwargs)
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = tmp_path
ctx.pending_checkpoints = []
ctx.checkpoint_tasks = {}
ctx.checkpoint_attempt_seq = {}
ctx.checkpoint_repairs = {}
ctx.assert_halt = False
ctx.final_check_attempts = 0
ctx.planner_task = None
ctx.last_validated_plan = None
ctx.pending_validated_plan = None
ctx.task_plan_content_before = None
return ctx
def _make_state(**overrides):
state = MagicMock(spec=State)
state.initial_goal = "the user goal"
state.user_stop_requested = False
state.operator_feedback = None
state.operator_raw_data = None
state.structured_decisions = None
defaults = {
"operator_raw_thinking": None,
"operator_native_thinking": None,
}
for k, v in {**defaults, **overrides}.items():
setattr(state, k, v)
return state
def _write_plan(tmp_path, content=PLAN_WITH_CHECKS):
notes = tmp_path / "notes"
notes.mkdir(parents=True, exist_ok=True)
path = notes / "task_plan.md"
path.write_text(content, encoding="utf-8")
return path
def _pending(plan=PLAN_WITH_CHECKS, kinds=("verify", "assert")):
snapshot = parse_plan(plan)
item = next(i for i in snapshot.top_level if i.key == GOAL_KEY)
items = tuple(
ci for ci in snapshot.check_items_of(item) if ci.when == "on_complete" and ci.kind in kinds
)
return PendingCheckpoint(
checkpoint_id=GOAL_KEY,
subgoal_text=GOAL_TEXT,
check_items=items,
plan_text=plan,
trigger_ts=1.0,
)
def _done_run(ctx, report_or_exc, attempt_id=f"{GOAL_KEY}#1", pending=None):
loop = asyncio.get_event_loop()
fut = loop.create_future()
if isinstance(report_or_exc, Exception):
fut.set_exception(report_or_exc)
else:
fut.set_result(report_or_exc)
return CheckpointRun(attempt_id=attempt_id, task=fut, checkpoint=pending or _pending())
def _verdict(kind="verify", status="failed", text=None, evidence="concrete evidence"):
default_text = "the alarm list shows 7:30 AM" if kind == "verify" else "a toast appeared"
return CheckVerdict(
item_text=text or default_text,
kind=kind,
status=status,
evidence=evidence,
suggestion="fix it" if (kind == "verify" and status == "failed") else "",
)
# --- §8.1 / §8.2: queueing ------------------------------------------------------------
def test_queue_checkpoints_enqueues_every_completion_with_items(tmp_path):
ctx = _make_ctx(tmp_path)
state = _make_state()
plan = (
"- [x] A\n - verify: VA\n"
"- [x] B\n - verify: VB\n"
"- [x] C\n" # no check items -> never queued
)
after = parse_plan(plan)
queue_checkpoints(ctx, state, after, ["A", "B", "C"], plan)
assert [p.checkpoint_id for p in ctx.pending_checkpoints] == [
subgoal_hash("A"),
subgoal_hash("B"),
]
# Only queued, never spawned here
assert ctx.checkpoint_tasks == {}
def test_queue_checkpoints_ignores_at_end_items(tmp_path):
ctx = _make_ctx(tmp_path)
plan = "- [x] A\n - assert@end: final only\n"
queue_checkpoints(ctx, _make_state(), parse_plan(plan), ["A"], plan)
assert ctx.pending_checkpoints == []
def test_queue_checkpoints_respects_midway_gate_and_user_stop(tmp_path):
plan = "- [x] A\n - verify: VA\n"
after = parse_plan(plan)
ctx = _make_ctx(tmp_path, disable_midway_checks=True)
queue_checkpoints(ctx, _make_state(), after, ["A"], plan)
assert ctx.pending_checkpoints == []
# Legacy master alias also gates
ctx2 = _make_ctx(tmp_path, disable_checker=True)
queue_checkpoints(ctx2, _make_state(), after, ["A"], plan)
assert ctx2.pending_checkpoints == []
ctx3 = _make_ctx(tmp_path)
queue_checkpoints(ctx3, _make_state(user_stop_requested=True), after, ["A"], plan)
assert ctx3.pending_checkpoints == []
@pytest.mark.asyncio
async def test_process_plan_write_only_queues(tmp_path):
"""The plan-write pipeline enqueues but never spawns: spawning waits for the
turn's step to be recorded."""
plan_before = PLAN_WITH_CHECKS.replace(f"- [x] {GOAL_TEXT}", f"- [/] {GOAL_TEXT}")
task_plan_path = _write_plan(tmp_path, plan_before)
ctx = _make_ctx(tmp_path)
state = _make_state()
async def fake_invoke(tool, args, tool_call_id, state, record_trace=None):
task_plan_path.write_text(args["content"], encoding="utf-8")
return "Success"
mock_tool = MagicMock()
mock_tool.name = "save_note"
mock_tool.description = "save"
with patch("artemis.graph.graph.invoke_tool_with_injection", side_effect=fake_invoke):
wrapped = wrap_note_tool(ctx, mock_tool)
await wrapped.ainvoke(
{"key": "task_plan", "content": PLAN_WITH_CHECKS, "tool_call_id": "t1"}
)
assert len(ctx.pending_checkpoints) == 1
pc = ctx.pending_checkpoints[0]
assert pc.checkpoint_id == GOAL_KEY
assert [ci.kind for ci in pc.check_items] == ["verify", "assert"]
assert pc.plan_text == PLAN_WITH_CHECKS
assert ctx.checkpoint_tasks == {}
# --- §8.1 / §8.10: spawn after record_step; record_step unconditional ----------------
def _raw_data():
return {
"screenshot_b64": base64.b64encode(b"img").decode(),
"xml_hierarchy": [],
"ocr_results": [],
"width": 1080,
"height": 2400,
}
@pytest.mark.asyncio
async def test_execution_check_spawns_after_record_step_with_correct_anchor(tmp_path):
_write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
ctx.data_engine.record_step.return_value = "step-uuid-42"
ctx.pending_checkpoints.append(_pending())
state = _make_state(operator_raw_data=_raw_data())
captured = {}
async def fake_check(ctx_arg, check_items, anchor, goal, subgoal_text):
captured["anchor"] = anchor
captured["goal"] = goal
return CheckReport(verdicts=[])
with (
patch(
"artemis.agents.checker.checker.run_checkpoint_check",
side_effect=fake_check,
),
patch("artemis.graph.graph._get_active_subgoal_hashes", return_value=("h", None)),
):
update = await execution_check_node(state, ctx)
assert ctx.data_engine.record_step.called
assert update["current_step_id"] == "step-uuid-42"
assert GOAL_KEY in ctx.checkpoint_tasks
# Let the spawned task run
await ctx.checkpoint_tasks[GOAL_KEY].task
assert captured["anchor"].anchor_step_id == "step-uuid-42"
assert captured["anchor"].plan_text == PLAN_WITH_CHECKS
assert captured["goal"] == "the user goal"
@pytest.mark.asyncio
async def test_execution_check_no_spawn_after_user_stop(tmp_path):
_write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
ctx.data_engine.record_step.return_value = "sid"
ctx.pending_checkpoints.append(_pending())
state = _make_state(operator_raw_data=_raw_data(), user_stop_requested=True)
with patch("artemis.graph.graph._get_active_subgoal_hashes", return_value=("h", None)):
await execution_check_node(state, ctx)
assert ctx.checkpoint_tasks == {}
# Step is still recorded regardless
assert ctx.data_engine.record_step.called
@pytest.mark.asyncio
async def test_record_step_on_planner_rejected_turn(tmp_path):
"""Every operator turn leaves a step record — the planner-rejected turn is
recorded too, tagged planner_rejected."""
_write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
ctx.data_engine.record_step.return_value = "rejected-step"
rejection = asyncio.get_event_loop().create_future()
rejection.set_result({"status": "failed", "feedback": "bad plan"})
ctx.planner_task = rejection
state = _make_state(operator_raw_data=_raw_data())
with patch("artemis.graph.graph._get_active_subgoal_hashes", return_value=("h", None)):
update = await execution_check_node(state, ctx)
assert update["checker_success"] is False
assert update["current_step_id"] == "rejected-step"
extra = ctx.data_engine.record_step.call_args.kwargs["extra_metadata"]
assert extra.get("planner_rejected") is True
@pytest.mark.asyncio
async def test_concurrency_cap_leaves_excess_pending(tmp_path):
_write_plan(tmp_path)
ctx = _make_ctx(tmp_path, max_concurrent_checkpoints=1)
plans = []
for name in ("A", "B"):
plan = f"- [x] {name}\n - verify: V{name}\n"
plans.append(plan)
snapshot = parse_plan(plan)
item = snapshot.top_level[0]
ctx.pending_checkpoints.append(
PendingCheckpoint(
checkpoint_id=item.key,
subgoal_text=name,
check_items=snapshot.check_items_of(item),
plan_text=plan,
trigger_ts=0.0,
)
)
async def fake_check(*a, **k):
return CheckReport(verdicts=[])
with patch("artemis.agents.checker.checker.run_checkpoint_check", side_effect=fake_check):
await spawn_pending_checkpoints(ctx, _make_state(), "sid")
assert len(ctx.checkpoint_tasks) == 1
assert len(ctx.pending_checkpoints) == 1
for run in ctx.checkpoint_tasks.values():
await run.task
# --- §8.3: supersede never loses verdicts --------------------------------------------
@pytest.mark.asyncio
async def test_supersede_books_finished_unharvested_attempt_first(tmp_path):
_write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
failed_report = CheckReport(verdicts=[_verdict("verify", "failed")])
ctx.checkpoint_tasks[GOAL_KEY] = _done_run(ctx, failed_report)
ctx.checkpoint_attempt_seq[GOAL_KEY] = 1
ctx.pending_checkpoints.append(_pending())
async def fake_check(*a, **k):
return CheckReport(verdicts=[])
with patch("artemis.agents.checker.checker.run_checkpoint_check", side_effect=fake_check):
await spawn_pending_checkpoints(ctx, _make_state(), "sid")
new_run = ctx.checkpoint_tasks[GOAL_KEY]
await new_run.task
# The failed verdict was booked before replacement — never dropped
records = read_ledger(tmp_path)
failed = [r for r in records if r["status"] == "failed"]
assert len(failed) == 1
assert failed[0]["attempt_id"] == f"{GOAL_KEY}#1"
# And a fresh attempt id was allocated
assert new_run.attempt_id == f"{GOAL_KEY}#2"
# Superseded harvest is ledger-only: no plan revert happened
assert "- [x]" in (tmp_path / "notes" / "task_plan.md").read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_supersede_cancels_running_attempt_and_books_superseded(tmp_path):
_write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
async def hang():
await asyncio.sleep(3600)
running = asyncio.create_task(hang())
await asyncio.sleep(0) # let it start
ctx.checkpoint_tasks[GOAL_KEY] = CheckpointRun(
attempt_id=f"{GOAL_KEY}#1", task=running, checkpoint=_pending()
)
ctx.checkpoint_attempt_seq[GOAL_KEY] = 1
ctx.pending_checkpoints.append(_pending())
async def fake_check(*a, **k):
return CheckReport(verdicts=[])
with patch("artemis.agents.checker.checker.run_checkpoint_check", side_effect=fake_check):
await spawn_pending_checkpoints(ctx, _make_state(), "sid")
await ctx.checkpoint_tasks[GOAL_KEY].task
assert running.cancelled()
superseded = [r for r in read_ledger(tmp_path) if r["status"] == "superseded"]
assert superseded and superseded[0]["attempt_id"] == f"{GOAL_KEY}#1"
# --- §8.4: harvest only takes done(); applicability gates side effects ---------------
@pytest.mark.asyncio
async def test_harvest_only_takes_done_tasks(tmp_path):
_write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
async def hang():
await asyncio.sleep(3600)
running = asyncio.create_task(hang())
await asyncio.sleep(0)
ctx.checkpoint_tasks["running"] = CheckpointRun(
attempt_id="running#1", task=running, checkpoint=_pending()
)
ctx.checkpoint_tasks[GOAL_KEY] = _done_run(
ctx, CheckReport(verdicts=[_verdict("verify", "passed")])
)
findings = harvest_finished_checkpoints(ctx, _make_state())
assert findings == []
# The running attempt is untouched (never awaited, never removed)
assert "running" in ctx.checkpoint_tasks
assert GOAL_KEY not in ctx.checkpoint_tasks
assert not running.done()
running.cancel()
with pytest.raises(asyncio.CancelledError):
await running
@pytest.mark.asyncio
async def test_stale_verdict_is_ledger_only_when_subgoal_text_changed(tmp_path):
# Plan no longer contains the anchored subgoal text
_write_plan(tmp_path, "- [x] Create the alarm (rephrased)\n")
ctx = _make_ctx(tmp_path)
run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")]))
findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True)
assert findings == [] # no repair injection
assert ctx.checkpoint_repairs == {}
records = read_ledger(tmp_path)
assert records and records[0]["status"] == "failed" # but still booked
# --- §8.5: verify FAIL repairs, bounded by quota -------------------------------------
@pytest.mark.asyncio
async def test_verify_fail_reverts_subgoal_and_injects_finding(tmp_path):
plan_path = _write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")]))
findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True)
content = plan_path.read_text(encoding="utf-8")
assert f"- [/] {GOAL_TEXT}" in content # [x] -> [/], forward-looking
assert any("verify failed" in f for f in findings)
assert any("fix it" in f for f in findings)
assert ctx.checkpoint_repairs[GOAL_KEY] == 1
@pytest.mark.asyncio
async def test_verify_fail_beyond_repair_quota_keeps_failed_without_revert(tmp_path):
plan_path = _write_plan(tmp_path)
ctx = _make_ctx(tmp_path, checkpoint_max_repairs=2)
ctx.checkpoint_repairs[GOAL_KEY] = 2
run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")]))
findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True)
assert findings == []
assert f"- [x] {GOAL_TEXT}" in plan_path.read_text(encoding="utf-8")
# The verdict is NOT rewritten: it stays failed in the ledger
assert read_ledger(tmp_path)[0]["status"] == "failed"
@pytest.mark.asyncio
async def test_verify_fail_after_user_stop_never_reverts(tmp_path):
plan_path = _write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")]))
harvest_run(ctx, _make_state(user_stop_requested=True), run, allow_side_effects=True)
assert f"- [x] {GOAL_TEXT}" in plan_path.read_text(encoding="utf-8")
# --- §8.6: assert FAIL is a result, never a repair -----------------------------------
@pytest.mark.asyncio
async def test_assert_fail_is_ledger_only(tmp_path):
plan_path = _write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
run = _done_run(ctx, CheckReport(verdicts=[_verdict("assert", "failed")]))
findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True)
assert findings == []
assert f"- [x] {GOAL_TEXT}" in plan_path.read_text(encoding="utf-8")
assert ctx.assert_halt is False
assert read_ledger(tmp_path)[0]["status"] == "failed"
@pytest.mark.asyncio
async def test_assert_fail_halt_policy_latches_halt(tmp_path):
_write_plan(tmp_path)
ctx = _make_ctx(tmp_path, assert_failure_policy="halt")
run = _done_run(ctx, CheckReport(verdicts=[_verdict("assert", "failed")]))
harvest_run(ctx, _make_state(), run, allow_side_effects=True)
assert ctx.assert_halt is True
@pytest.mark.asyncio
async def test_ledger_is_append_only_fail_then_pass_both_present(tmp_path):
_write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
run1 = _done_run(ctx, CheckReport(verdicts=[_verdict("assert", "failed")]))
harvest_run(ctx, _make_state(), run1, allow_side_effects=True)
run2 = _done_run(
ctx,
CheckReport(verdicts=[_verdict("assert", "passed")]),
attempt_id=f"{GOAL_KEY}#2",
)
harvest_run(ctx, _make_state(), run2, allow_side_effects=True)
statuses = [r["status"] for r in read_ledger(tmp_path)]
assert statuses == ["failed", "passed"] # first failure permanently retained
# --- §8.7 / §8.13: timeouts and fail-open axis separation ----------------------------
@pytest.mark.asyncio
async def test_timeout_records_inconclusive_not_passed(tmp_path):
_write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
run = _done_run(ctx, TimeoutError())
findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True)
assert findings == [] # released (fail-open): no repair loop
records = read_ledger(tmp_path)
assert {r["status"] for r in records} == {"inconclusive"}
assert all(r["status"] != "passed" for r in records)
@pytest.mark.asyncio
async def test_exception_fail_open_releases_but_books_inconclusive(tmp_path):
plan_path = _write_plan(tmp_path)
ctx = _make_ctx(tmp_path)
run = _done_run(ctx, RuntimeError("checker blew up"))
findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True)
# Release: no revert, no finding
assert findings == []
assert f"- [x] {GOAL_TEXT}" in plan_path.read_text(encoding="utf-8")
# Verdict axis: inconclusive, never rewritten to passed
for r in read_ledger(tmp_path):
assert r["status"] == "inconclusive"
assert "checker blew up" in r["evidence"]
+446
View File
@@ -0,0 +1,446 @@
# 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.
"""Exit settlement tests: gate routing, settlement/final-review decoupling,
settlement timeout, when-semantics at the exit, run outcome, and the four
switch-combination matrix."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from artemis.agents.checker.checker import CheckReport, CheckVerdict
from artemis.context import ArtemisContext, ExecutionSetup
from artemis.graph.checkpoints import (
CheckpointRun,
PendingCheckpoint,
append_ledger_record,
compute_run_outcome,
read_ledger,
resolve_item_status,
settle_all_checkpoints,
)
from artemis.graph.graph import (
convergence_gate,
exit_settlement_gate,
exit_settlement_node,
)
from artemis.graph.state import State
from artemis.sdk.agent import attach_test_summary, resolve_trace_suffix
from artemis.utils.plan_grammar import CheckItem, parse_plan, subgoal_hash
DONE_PLAN_WITH_CHECKS = (
"- [x] Create alarm\n"
" - verify: alarm exists\n"
" - assert: toast appeared\n"
"- assert@end: no crash dialog\n"
)
DONE_PLAN_NO_CHECKS = "- [x] Create alarm\n"
def _make_ctx(tmp_path, **setup_kwargs):
setup_kwargs.setdefault("disable_checker", False)
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = ExecutionSetup(**setup_kwargs)
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = tmp_path
ctx.pending_checkpoints = []
ctx.checkpoint_tasks = {}
ctx.checkpoint_attempt_seq = {}
ctx.checkpoint_repairs = {}
ctx.assert_halt = False
ctx.final_check_attempts = 0
return ctx
def _make_state(**overrides):
state = MagicMock(spec=State)
state.initial_goal = "the user goal"
state.user_stop_requested = False
state.checker_success = True
for k, v in overrides.items():
setattr(state, k, v)
return state
def _write_plan(tmp_path, content):
notes = tmp_path / "notes"
notes.mkdir(parents=True, exist_ok=True)
(notes / "task_plan.md").write_text(content, encoding="utf-8")
def _pending_checkpoint(text="Create alarm"):
return PendingCheckpoint(
checkpoint_id=subgoal_hash(text),
subgoal_text=text,
check_items=(
CheckItem(
kind="verify",
when="on_complete",
text="alarm exists",
parent_key=subgoal_hash(text),
),
),
plan_text=f"- [x] {text}\n",
trigger_ts=0.0,
)
def _hanging_run(cid="cid"):
async def hang():
await asyncio.sleep(3600)
task = asyncio.create_task(hang())
return CheckpointRun(attempt_id=f"{cid}#1", task=task, checkpoint=_pending_checkpoint())
# --- §8.8: gate routing — settlement decoupled from final review ---------------------
def test_gate_routes_settlement_when_tasks_pending_even_with_final_off(tmp_path):
_write_plan(tmp_path, DONE_PLAN_NO_CHECKS)
ctx = _make_ctx(tmp_path, disable_final_check=True)
ctx.checkpoint_tasks["cid"] = MagicMock()
assert convergence_gate(_make_state(), ctx) == "exit_settlement"
def test_gate_routes_empty_plan_to_final_review_when_enabled(tmp_path):
_write_plan(tmp_path, "just prose, no checkboxes\n")
ctx = _make_ctx(tmp_path) # final on (enabled master, default gates)
assert convergence_gate(_make_state(), ctx) == "exit_settlement"
def test_gate_ends_directly_when_nothing_check_related(tmp_path):
_write_plan(tmp_path, DONE_PLAN_NO_CHECKS)
ctx = _make_ctx(tmp_path, disable_checker=True)
assert convergence_gate(_make_state(), ctx) == "end"
def test_gate_check_items_force_settlement_even_all_switches_off(tmp_path):
"""Combination row 1 (off/off): declared items still get settled (booked as
unchecked) instead of silently vanishing."""
_write_plan(tmp_path, DONE_PLAN_WITH_CHECKS)
ctx = _make_ctx(tmp_path, disable_checker=True)
assert convergence_gate(_make_state(), ctx) == "exit_settlement"
def test_gate_incomplete_plan_continues(tmp_path):
_write_plan(tmp_path, "- [/] Create alarm\n - verify: alarm exists\n")
ctx = _make_ctx(tmp_path)
assert convergence_gate(_make_state(), ctx) == "continue"
def test_gate_assert_halt_terminates_mid_plan(tmp_path):
_write_plan(tmp_path, "- [/] Create alarm\n - assert: toast appeared\n")
ctx = _make_ctx(tmp_path)
ctx.assert_halt = True
assert convergence_gate(_make_state(), ctx) == "exit_settlement"
@pytest.mark.asyncio
async def test_settlement_runs_phase_one_only_when_final_disabled(tmp_path):
_write_plan(tmp_path, DONE_PLAN_WITH_CHECKS)
ctx = _make_ctx(tmp_path, disable_final_check=True)
report = CheckReport(
verdicts=[
CheckVerdict(item_text="alarm exists", kind="verify", status="passed", evidence="e")
]
)
fut = asyncio.get_event_loop().create_future()
fut.set_result(report)
cid = subgoal_hash("Create alarm")
ctx.checkpoint_tasks[cid] = CheckpointRun(
attempt_id=f"{cid}#1", task=fut, checkpoint=_pending_checkpoint()
)
with patch("artemis.graph.graph.run_final_check", new=AsyncMock()) as final_mock:
update = await exit_settlement_node(_make_state(), ctx)
final_mock.assert_not_awaited()
assert update["exit_settlement_route"] == "end"
# Phase one still booked the attempt and produced the summary
assert any(r["status"] == "passed" for r in read_ledger(tmp_path))
assert update["run_outcome"]["tests"]["passed"] == 1
assert ctx.checkpoint_tasks == {}
# --- §8.7: settlement timeout --------------------------------------------------------
@pytest.mark.asyncio
async def test_settlement_timeout_cancels_and_books_unchecked(tmp_path):
_write_plan(tmp_path, DONE_PLAN_NO_CHECKS)
ctx = _make_ctx(tmp_path, settlement_timeout=0.05)
run = _hanging_run()
ctx.checkpoint_tasks["cid"] = run
await settle_all_checkpoints(ctx, _make_state())
assert run.task.cancelled()
records = read_ledger(tmp_path)
assert records and all(r["status"] == "unchecked" for r in records)
assert "settlement timeout" in records[0]["evidence"]
# --- §8.9: when-semantics at the exit ------------------------------------------------
@pytest.mark.asyncio
async def test_final_check_receives_ledger_and_at_end_items(tmp_path):
_write_plan(tmp_path, DONE_PLAN_WITH_CHECKS)
ctx = _make_ctx(tmp_path)
append_ledger_record(
tmp_path,
{
"attempt_id": "x#1",
"checkpoint_id": "x",
"item_text": "toast appeared",
"kind": "assert",
"when": "on_complete",
"status": "failed",
"evidence": "no toast in anchor history",
},
)
captured = {}
async def fake_final(ctx_arg, goal, plan_text, ledger, check_items):
captured["goal"] = goal
captured["ledger"] = ledger
captured["items"] = list(check_items)
return CheckReport(verdicts=[])
with patch("artemis.graph.graph.run_final_check", side_effect=fake_final):
await exit_settlement_node(_make_state(), ctx)
# Final review audits the user's original goal, citing the ledger
assert captured["goal"] == "the user goal"
assert any(r["status"] == "failed" for r in captured["ledger"])
whens = {(ci.text, ci.when) for ci in captured["items"]}
assert ("no crash dialog", "at_end") in whens
assert ("alarm exists", "on_complete") in whens
def test_on_complete_without_history_evidence_never_passes():
"""The 'current state satisfied but historical evidence missing' case: no
substantive record -> unchecked; a final inconclusive verdict stays
inconclusive. Neither ever resolves to passed."""
assert resolve_item_status("assert", []) == "unchecked"
inconclusive_only = [{"status": "inconclusive", "evidence": "evidence missing for past moment"}]
assert resolve_item_status("assert", inconclusive_only) == "inconclusive"
# First assert failure is permanent even if a later record passes
assert resolve_item_status("assert", [{"status": "failed"}, {"status": "passed"}]) == "failed"
# Verify: latest substantive verdict wins (repair-then-pass is passed)
assert resolve_item_status("verify", [{"status": "failed"}, {"status": "passed"}]) == "passed"
# --- §8.14: run outcome + SDK wrap-up ------------------------------------------------
@pytest.mark.asyncio
async def test_assert_failure_yields_failed_tests_but_completed_status(tmp_path):
_write_plan(tmp_path, DONE_PLAN_WITH_CHECKS)
ctx = _make_ctx(tmp_path)
report = CheckReport(
verdicts=[
CheckVerdict(item_text="alarm exists", kind="verify", status="passed", evidence="e"),
CheckVerdict(item_text="toast appeared", kind="assert", status="failed", evidence="e"),
CheckVerdict(item_text="no crash dialog", kind="assert", status="passed", evidence="e"),
]
)
with patch("artemis.graph.graph.run_final_check", new=AsyncMock(return_value=report)):
update = await exit_settlement_node(_make_state(), ctx)
# Assert failures never bounce the task back
assert update["exit_settlement_route"] == "end"
outcome = update["run_outcome"]
assert outcome["task_status"] == "completed"
assert outcome["tests"]["failed"] == 1
assert outcome["tests"]["passed"] == 2
assert outcome["tests"]["failed_items"][0]["item_text"] == "toast appeared"
# SDK wrap-up: trace suffix distinguishes assertion failures from _PASS
assert resolve_trace_suffix("completed", outcome) == "_TESTFAIL"
assert resolve_trace_suffix("completed", {"tests": {"failed": 0}}) == "_PASS"
assert resolve_trace_suffix("failed", outcome) == "_FAIL"
# And the return structure surfaces the machine-readable summary
wrapped = attach_test_summary("report text", outcome)
assert wrapped["result"] == "report text"
assert wrapped["test_summary"]["failed"] == 1
as_dict = attach_test_summary({"answer": 42}, outcome)
assert as_dict["answer"] == 42 and as_dict["test_summary"]["failed"] == 1
@pytest.mark.asyncio
async def test_verify_unmet_bounces_back_then_blocks_on_budget_exhaustion(tmp_path):
_write_plan(tmp_path, DONE_PLAN_WITH_CHECKS)
ctx = _make_ctx(tmp_path, final_check_max_attempts=2)
failing = CheckReport(
verdicts=[
CheckVerdict(
item_text="alarm exists",
kind="verify",
status="failed",
evidence="e",
suggestion="create it",
)
],
unmet_subgoals=["Create alarm"],
)
with patch("artemis.graph.graph.run_final_check", new=AsyncMock(return_value=failing)):
first = await exit_settlement_node(_make_state(), ctx)
assert first["exit_settlement_route"] == "continue"
assert exit_settlement_gate(_make_state(exit_settlement_route="continue")) == "continue"
# The unmet subgoal was reverted forward to in-progress
plan = (tmp_path / "notes" / "task_plan.md").read_text(encoding="utf-8")
assert "- [/] Create alarm" in plan
assert any("verify failed" in f for f in first["operator_feedback"])
# Re-complete and exhaust the budget
_write_plan(tmp_path, DONE_PLAN_WITH_CHECKS)
second = await exit_settlement_node(_make_state(), ctx)
assert ctx.final_check_attempts == 2
assert second["exit_settlement_route"] == "end"
outcome = second["run_outcome"]
assert outcome["task_status"] == "blocked"
def test_no_run_outcome_without_check_material(tmp_path):
"""§1.3: the test summary only exists when check items exist."""
outcome = compute_run_outcome(parse_plan(DONE_PLAN_NO_CHECKS), [], verify_blocked=False)
assert outcome.tests.passed + outcome.tests.failed == 0
assert attach_test_summary("plain", outcome.model_dump()) == "plain"
# --- §8.15: continuous + user_stop + FAIL never loops back ---------------------------
@pytest.mark.asyncio
async def test_user_stop_final_fail_does_not_reenter_loop(tmp_path):
plan = "- [x] [Loop:continuous] Monitor inbox\n - verify: monitoring log is complete\n"
_write_plan(tmp_path, plan)
ctx = _make_ctx(tmp_path)
failing = CheckReport(
verdicts=[
CheckVerdict(
item_text="monitoring log is complete",
kind="verify",
status="failed",
evidence="gaps in the log",
)
]
)
with patch("artemis.graph.graph.run_final_check", new=AsyncMock(return_value=failing)):
update = await exit_settlement_node(_make_state(user_stop_requested=True), ctx)
assert update["exit_settlement_route"] == "end"
assert update["run_outcome"]["task_status"] == "blocked"
# --- §8.16: four switch-combination matrix -------------------------------------------
def _fresh_final_report():
return CheckReport(
verdicts=[
CheckVerdict(item_text="no crash dialog", kind="assert", status="passed", evidence="e")
]
)
@pytest.mark.asyncio
async def test_matrix_off_off_reports_all_unchecked(tmp_path):
_write_plan(tmp_path, DONE_PLAN_WITH_CHECKS)
ctx = _make_ctx(tmp_path, disable_midway_checks=True, disable_final_check=True)
with patch("artemis.graph.graph.run_final_check", new=AsyncMock()) as final_mock:
update = await exit_settlement_node(_make_state(), ctx)
final_mock.assert_not_awaited()
outcome = update["run_outcome"]
assert outcome["tests"]["unchecked"] == 3
assert outcome["tests"]["passed"] == 0
@pytest.mark.asyncio
async def test_matrix_off_on_final_judges_from_state_and_history(tmp_path):
_write_plan(tmp_path, DONE_PLAN_WITH_CHECKS)
ctx = _make_ctx(tmp_path, disable_midway_checks=True)
report = CheckReport(
verdicts=[
CheckVerdict(item_text="no crash dialog", kind="assert", status="passed", evidence="e"),
CheckVerdict(item_text="alarm exists", kind="verify", status="passed", evidence="e"),
CheckVerdict(
item_text="toast appeared",
kind="assert",
status="inconclusive",
evidence="transient evidence was never captured",
),
]
)
with patch("artemis.graph.graph.run_final_check", new=AsyncMock(return_value=report)):
update = await exit_settlement_node(_make_state(), ctx)
outcome = update["run_outcome"]
assert outcome["tests"]["passed"] == 2
assert outcome["tests"]["inconclusive"] == 1
assert outcome["tests"]["failed"] == 0
@pytest.mark.asyncio
async def test_matrix_on_off_settles_without_final(tmp_path):
# covered structurally by test_settlement_runs_phase_one_only_when_final_disabled;
# here assert the summary is still produced with unchecked leftovers.
_write_plan(tmp_path, DONE_PLAN_WITH_CHECKS)
ctx = _make_ctx(tmp_path, disable_final_check=True)
with patch("artemis.graph.graph.run_final_check", new=AsyncMock()) as final_mock:
update = await exit_settlement_node(_make_state(), ctx)
final_mock.assert_not_awaited()
assert update["run_outcome"]["tests"]["unchecked"] == 3
@pytest.mark.asyncio
async def test_matrix_on_on_full_pipeline(tmp_path):
_write_plan(tmp_path, DONE_PLAN_WITH_CHECKS)
ctx = _make_ctx(tmp_path)
# A checkpoint verdict already in the ledger...
append_ledger_record(
tmp_path,
{
"attempt_id": "c#1",
"checkpoint_id": "c",
"item_text": "alarm exists",
"kind": "verify",
"when": "on_complete",
"status": "passed",
"evidence": "probe output",
},
)
# ...and the final review supplements the rest.
report = CheckReport(
verdicts=[
CheckVerdict(item_text="no crash dialog", kind="assert", status="passed", evidence="e"),
CheckVerdict(item_text="toast appeared", kind="assert", status="failed", evidence="e"),
]
)
with patch("artemis.graph.graph.run_final_check", new=AsyncMock(return_value=report)):
update = await exit_settlement_node(_make_state(), ctx)
outcome = update["run_outcome"]
assert outcome["tests"]["passed"] == 2
assert outcome["tests"]["failed"] == 1
assert outcome["tests"]["unchecked"] == 0
+218 -191
View File
@@ -14,218 +14,232 @@
from unittest.mock import AsyncMock, MagicMock, patch
from langchain_core.messages import ToolMessage
from artemis.context import ArtemisContext
from artemis.graph.graph import wrap_note_tool, wrap_update_note_tool
import pytest
ORIGINAL_PLAN = """# Test Plan
- [ ] Milestone 1
- [ ] Substep 1.1
- [ ] Milestone 2
"""
def _make_ctx(base_dir, *, disable_checker=True, disable_planner_validation=False):
ctx = MagicMock(spec=ArtemisContext)
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = str(base_dir)
ctx.execution_setup = MagicMock()
ctx.execution_setup.disable_checker = disable_checker
ctx.execution_setup.disable_planner_validation = disable_planner_validation
ctx.planner_task = None
ctx.last_validated_plan = None
ctx.pending_checkpoints = []
ctx.checkpoint_tasks = {}
ctx.pending_validated_plan = None
ctx.task_plan_content_before = None
return ctx
def _setup_plan(tmp_path, content=ORIGINAL_PLAN):
notes_dir = tmp_path / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
task_plan_path = notes_dir / "task_plan.md"
task_plan_path.write_text(content, encoding="utf-8")
return task_plan_path
def _mock_tool(name):
tool = MagicMock()
tool.name = name
tool.description = f"{name} tool"
tool.ainvoke = AsyncMock(return_value="Success")
return tool
def _fake_update_invoke(task_plan_path):
async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None):
content = task_plan_path.read_text(encoding="utf-8")
updated = content.replace(args["target"], args["replacement"])
task_plan_path.write_text(updated, encoding="utf-8")
return "Success"
return fake_invoke_tool
def _fake_save_invoke(task_plan_path):
async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None):
task_plan_path.write_text(args["content"], encoding="utf-8")
return "Success"
return fake_invoke_tool
@pytest.mark.asyncio
async def test_wrap_update_note_tool_valid_status_change(tmp_path):
base_dir = tmp_path
notes_dir = base_dir / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
task_plan_path = notes_dir / "task_plan.md"
original_plan = """# Test Plan
- [ ] Milestone 1
- [ ] Substep 1.1
- [ ] Milestone 2
"""
task_plan_path.write_text(original_plan, encoding="utf-8")
mock_ctx = MagicMock(spec=ArtemisContext)
mock_ctx.data_engine = MagicMock()
mock_ctx.data_engine.base_dir = str(base_dir)
mock_ctx.execution_setup = None
mock_original_tool = MagicMock()
mock_original_tool.name = "update_note"
mock_original_tool.description = "Update a note"
mock_original_tool.ainvoke = AsyncMock(return_value="Success")
async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None):
content = task_plan_path.read_text(encoding="utf-8")
updated = content.replace(args["target"], args["replacement"])
task_plan_path.write_text(updated, encoding="utf-8")
return "Success"
target = "- [ ] Milestone 1"
replacement = "- [x] Milestone 1"
task_plan_path = _setup_plan(tmp_path)
ctx = _make_ctx(tmp_path)
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=fake_invoke_tool,
side_effect=_fake_update_invoke(task_plan_path),
):
# Instantiate inside patch context to capture the mocked import locally
wrapped_tool = wrap_update_note_tool(mock_ctx, mock_original_tool)
wrapped_tool = wrap_update_note_tool(ctx, _mock_tool("update_note"))
result = await wrapped_tool.ainvoke(
{
"key": "task_plan",
"target": target,
"replacement": replacement,
"target": "- [ ] Milestone 1",
"replacement": "- [x] Milestone 1",
"tool_call_id": "test_call_id",
}
)
assert result == "Success"
updated_content = task_plan_path.read_text(encoding="utf-8")
assert "- [x] Milestone 1" in updated_content
assert "- [ ] Milestone 2" in updated_content
assert result == "Success"
updated_content = task_plan_path.read_text(encoding="utf-8")
assert "- [x] Milestone 1" in updated_content
assert "- [ ] Milestone 2" in updated_content
# Status flips never disturb the milestone texts: no validation spawned
assert ctx.planner_task is None
@pytest.mark.asyncio
async def test_wrap_update_note_tool_invalid_milestone_text_change(tmp_path):
base_dir = tmp_path
notes_dir = base_dir / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
task_plan_path = notes_dir / "task_plan.md"
async def test_wrap_update_note_tool_intentional_rename_triggers_validation(tmp_path):
task_plan_path = _setup_plan(tmp_path)
ctx = _make_ctx(tmp_path)
original_plan = """# Test Plan
- [ ] Milestone 1
- [ ] Substep 1.1
- [ ] Milestone 2
"""
task_plan_path.write_text(original_plan, encoding="utf-8")
mock_ctx = MagicMock(spec=ArtemisContext)
mock_ctx.data_engine = MagicMock()
mock_ctx.data_engine.base_dir = str(base_dir)
mock_ctx.execution_setup = None
mock_original_tool = MagicMock()
mock_original_tool.name = "update_note"
mock_original_tool.ainvoke = AsyncMock(return_value="Success")
async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None):
content = task_plan_path.read_text(encoding="utf-8")
updated = content.replace(args["target"], args["replacement"])
task_plan_path.write_text(updated, encoding="utf-8")
return "Success"
target = "- [ ] Milestone 1"
replacement = "- [ ] Milestone Renamed"
mock_validation = AsyncMock(return_value={"status": "failed", "feedback": "rejected"})
mock_validation = AsyncMock(return_value={"status": "success", "feedback": ""})
with (
patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=fake_invoke_tool,
),
patch(
"artemis.agents.planner.planner.run_async_planner_validation",
mock_validation,
side_effect=_fake_update_invoke(task_plan_path),
),
patch("artemis.graph.graph.run_async_planner_validation", mock_validation),
):
wrapped_tool = wrap_update_note_tool(mock_ctx, mock_original_tool)
wrapped_tool = wrap_update_note_tool(ctx, _mock_tool("update_note"))
result = await wrapped_tool.ainvoke(
{
"key": "task_plan",
"target": target,
"replacement": replacement,
"target": "- [ ] Milestone 1",
"replacement": "- [ ] Milestone Renamed",
"tool_call_id": "test_call_id",
}
)
assert result == "Success"
assert mock_ctx.planner_task is not None
# Declared update_note edit is applied, but ratchet validation fires
assert ctx.planner_task is not None
await ctx.planner_task
assert "Milestone Renamed" in task_plan_path.read_text(encoding="utf-8")
mock_validation.assert_called_once()
# Validation compares against the ratchet baseline (the original plan)
assert mock_validation.call_args.args[2] == ORIGINAL_PLAN
@pytest.mark.asyncio
async def test_ratchet_baseline_survives_consecutive_small_edits(tmp_path):
"""Salami-slicing: every small rename is judged against the ORIGINAL
validated baseline, not the immediately preceding write."""
import asyncio
task_plan_path = _setup_plan(tmp_path)
ctx = _make_ctx(tmp_path)
gate = asyncio.Event()
async def blocking_validation(*args, **kwargs):
await gate.wait()
return {"status": "success", "feedback": ""}
mock_validation = AsyncMock(side_effect=blocking_validation)
with (
patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=_fake_update_invoke(task_plan_path),
),
patch("artemis.graph.graph.run_async_planner_validation", mock_validation),
):
wrapped_tool = wrap_update_note_tool(ctx, _mock_tool("update_note"))
await wrapped_tool.ainvoke(
{
"key": "task_plan",
"target": "- [ ] Milestone 1",
"replacement": "- [ ] Milestone 1a",
"tool_call_id": "call_1",
}
)
first_task = ctx.planner_task
assert first_task is not None
await wrapped_tool.ainvoke(
{
"key": "task_plan",
"target": "- [ ] Milestone 1a",
"replacement": "- [ ] Milestone 1b",
"tool_call_id": "call_2",
}
)
second_task = ctx.planner_task
assert second_task is not first_task
# The superseded in-flight validation is cancelled
gate.set()
with pytest.raises(asyncio.CancelledError):
await first_task
await second_task
# Both validation runs were anchored to the original baseline
for call in mock_validation.call_args_list:
assert call.args[2] == ORIGINAL_PLAN
# Rollback target preserves the last baseline-consistent content
assert ctx.task_plan_content_before == ORIGINAL_PLAN
@pytest.mark.asyncio
async def test_wrap_update_note_tool_allow_subgoal_changes(tmp_path):
base_dir = tmp_path
notes_dir = base_dir / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
task_plan_path = notes_dir / "task_plan.md"
original_plan = """# Test Plan
- [ ] Milestone 1
- [ ] Substep 1.1
- [ ] Milestone 2
"""
task_plan_path.write_text(original_plan, encoding="utf-8")
mock_ctx = MagicMock(spec=ArtemisContext)
mock_ctx.data_engine = MagicMock()
mock_ctx.data_engine.base_dir = str(base_dir)
mock_ctx.execution_setup = None
mock_original_tool = MagicMock()
mock_original_tool.name = "update_note"
mock_original_tool.ainvoke = AsyncMock(return_value="Success")
async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None):
content = task_plan_path.read_text(encoding="utf-8")
updated = content.replace(args["target"], args["replacement"])
task_plan_path.write_text(updated, encoding="utf-8")
return "Success"
target = " - [ ] Substep 1.1"
replacement = " - [x] Substep 1.1 Completed"
task_plan_path = _setup_plan(tmp_path)
ctx = _make_ctx(tmp_path)
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=fake_invoke_tool,
side_effect=_fake_update_invoke(task_plan_path),
):
wrapped_tool = wrap_update_note_tool(mock_ctx, mock_original_tool)
wrapped_tool = wrap_update_note_tool(ctx, _mock_tool("update_note"))
result = await wrapped_tool.ainvoke(
{
"key": "task_plan",
"target": target,
"replacement": replacement,
"target": " - [ ] Substep 1.1",
"replacement": " - [x] Substep 1.1 Completed",
"tool_call_id": "test_call_id",
}
)
assert result == "Success"
updated_content = task_plan_path.read_text(encoding="utf-8")
assert "Substep 1.1 Completed" in updated_content
assert "- [ ] Milestone 1" in updated_content
assert result == "Success"
updated_content = task_plan_path.read_text(encoding="utf-8")
assert "Substep 1.1 Completed" in updated_content
assert "- [ ] Milestone 1" in updated_content
# Sub-task edits never touch the top-level milestone texts
assert ctx.planner_task is None
@pytest.mark.asyncio
async def test_wrap_note_tool_invalid_rewrite(tmp_path):
base_dir = tmp_path
notes_dir = base_dir / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
task_plan_path = notes_dir / "task_plan.md"
original_plan = """# Test Plan
- [ ] Milestone 1
- [ ] Substep 1.1
- [ ] Milestone 2
"""
task_plan_path.write_text(original_plan, encoding="utf-8")
mock_ctx = MagicMock(spec=ArtemisContext)
mock_ctx.data_engine = MagicMock()
mock_ctx.data_engine.base_dir = str(base_dir)
mock_ctx.execution_setup = None
mock_original_tool = MagicMock()
mock_original_tool.name = "save_note"
mock_original_tool.ainvoke = AsyncMock(return_value="Success")
async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None):
task_plan_path.write_text(args["content"], encoding="utf-8")
return "Success"
async def test_wrap_note_tool_full_rewrite_hand_slip_rejected(tmp_path):
"""A save_note full rewrite that rewords status-unchanged milestones is the
hand-slip signature: rejected and rolled back, no validation spawned."""
task_plan_path = _setup_plan(tmp_path)
ctx = _make_ctx(tmp_path)
corrupted_plan = """# Test Plan
- [ ] Milestone 1 (Altered)
- [ ] New Milestone Added
"""
mock_validation = AsyncMock(return_value={"status": "failed", "feedback": "rejected"})
with (
patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=fake_invoke_tool,
),
patch(
"artemis.agents.planner.planner.run_async_planner_validation",
mock_validation,
),
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=_fake_save_invoke(task_plan_path),
):
wrapped_tool = wrap_note_tool(mock_ctx, mock_original_tool)
wrapped_tool = wrap_note_tool(ctx, _mock_tool("save_note"))
result = await wrapped_tool.ainvoke(
{
"key": "task_plan",
@@ -234,53 +248,30 @@ async def test_wrap_note_tool_invalid_rewrite(tmp_path):
}
)
assert result == "Success"
assert mock_ctx.planner_task is not None
assert isinstance(result, ToolMessage)
assert task_plan_path.read_text(encoding="utf-8") == ORIGINAL_PLAN
assert ctx.planner_task is None
@pytest.mark.asyncio
async def test_wrap_note_tool_valid_lexical_drift(tmp_path):
from langgraph.types import Command
from langchain_core.messages import ToolMessage
from artemis.constants import VALIDATOR_MESSAGES_KEY
base_dir = tmp_path
notes_dir = base_dir / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
task_plan_path = notes_dir / "task_plan.md"
async def test_wrap_note_tool_lexical_drift_rejected(tmp_path):
"""Even a tiny same-status rewording in a full rewrite bounces back with
guidance — this is exactly the drift that similarity thresholds missed."""
original_plan = """# Test Plan
- [ ] Open the Chrome browser and navigate to youtube.com
"""
task_plan_path.write_text(original_plan, encoding="utf-8")
task_plan_path = _setup_plan(tmp_path, original_plan)
ctx = _make_ctx(tmp_path)
mock_ctx = MagicMock(spec=ArtemisContext)
mock_ctx.data_engine = MagicMock()
mock_ctx.data_engine.base_dir = str(base_dir)
mock_ctx.execution_setup = None
# Create mock Command result
tool_msg = ToolMessage(tool_call_id="test_call_id", content="Success outcome", status="success")
mock_command = Command(update={VALIDATOR_MESSAGES_KEY: [tool_msg]})
mock_original_tool = MagicMock()
mock_original_tool.name = "save_note"
mock_original_tool.ainvoke = AsyncMock(return_value=mock_command)
async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None):
task_plan_path.write_text(args["content"], encoding="utf-8")
return mock_command
# Minor wording change that is semantically valid (similarity ~92%)
minor_drift_plan = """# Test Plan
- [ ] Open Chrome browser and navigate to youtube.com
"""
with patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=fake_invoke_tool,
side_effect=_fake_save_invoke(task_plan_path),
):
wrapped_tool = wrap_note_tool(mock_ctx, mock_original_tool)
wrapped_tool = wrap_note_tool(ctx, _mock_tool("save_note"))
result = await wrapped_tool.ainvoke(
{
"key": "task_plan",
@@ -289,9 +280,45 @@ async def test_wrap_note_tool_valid_lexical_drift(tmp_path):
}
)
assert isinstance(result, Command)
assert not hasattr(mock_ctx, "planner_task") or mock_ctx.planner_task is None
assert isinstance(result, ToolMessage)
assert "update_note" in str(result.content)
assert task_plan_path.read_text(encoding="utf-8") == original_plan
assert ctx.planner_task is None
# Verify that the drift plan is actually saved
saved_content = task_plan_path.read_text(encoding="utf-8")
assert "Open Chrome browser" in saved_content
@pytest.mark.asyncio
async def test_wrap_note_tool_structural_replan_triggers_validation(tmp_path):
"""Adding a milestone via full rewrite is a declared replan: applied, then
judged asynchronously by the planner against the ratchet baseline."""
task_plan_path = _setup_plan(tmp_path)
ctx = _make_ctx(tmp_path)
extended_plan = """# Test Plan
- [ ] Milestone 1
- [ ] Substep 1.1
- [ ] Milestone 2
- [ ] Milestone 3 (new phase)
"""
mock_validation = AsyncMock(return_value={"status": "success", "feedback": ""})
with (
patch(
"artemis.graph.graph.invoke_tool_with_injection",
side_effect=_fake_save_invoke(task_plan_path),
),
patch("artemis.graph.graph.run_async_planner_validation", mock_validation),
):
wrapped_tool = wrap_note_tool(ctx, _mock_tool("save_note"))
result = await wrapped_tool.ainvoke(
{
"key": "task_plan",
"content": extended_plan,
"tool_call_id": "test_call_id",
}
)
assert result == "Success"
assert ctx.planner_task is not None
await ctx.planner_task
assert "Milestone 3 (new phase)" in task_plan_path.read_text(encoding="utf-8")
mock_validation.assert_called_once()
+110
View File
@@ -0,0 +1,110 @@
# 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.
"""Phase 1 contract tests: explicit State + per-node visibility manifests."""
import pytest
from pydantic import ValidationError
from artemis.graph.state import State
from artemis.graph.visibility import (
NODE_VISIBILITY,
StateView,
VisibilityError,
check_update,
strict_state,
validate_manifest,
)
def test_initial_state_round_trips_through_channel_values():
"""sdk/agent.py rebuilds State(**values) from the astream values stream;
with extra="forbid" every dumped key must construct back cleanly."""
state = State.initial("test goal")
rebuilt = State(**state.model_dump())
assert rebuilt.initial_goal == "test goal"
assert rebuilt.subagent_calls == []
def test_unknown_constructor_key_is_rejected():
with pytest.raises(ValidationError):
State(initial_goal="g", ghost_key=1)
def test_unknown_attribute_write_is_rejected():
state = State.initial("g")
with pytest.raises(ValueError):
state.ui_tree = [] # the historical ghost key must never come back
def test_deleted_dead_fields_are_gone():
for dead in (
"messages",
"validator_messages",
"remaining_steps",
"focused_app_info",
"device_date",
"subgoal_plan",
"operator_tactical_plan",
"complete_subgoals_by_ids",
"current_agent",
"operator_replan_reason",
"check_feedback",
):
assert dead not in State.model_fields, dead
def test_manifest_only_references_declared_fields():
assert validate_manifest() == []
def test_state_view_allows_declared_and_blocks_undeclared_reads():
state = State.initial("g")
view = StateView(state, "operator")
assert view.initial_goal == "g" # declared read
with pytest.raises(VisibilityError):
_ = view.exit_settlement_route # not in operator's manifest
def test_state_view_blocks_undeclared_mutation():
state = State.initial("g")
view = StateView(state, "summarizer") # summarizer declares no writes
with pytest.raises(VisibilityError):
view.structured_decisions = "[]"
def test_state_view_passes_methods_through():
view = StateView(State.initial("g"), "operator")
dumped = view.model_dump()
assert dumped["initial_goal"] == "g"
def test_check_update_flags_undeclared_write():
with pytest.raises(VisibilityError):
check_update("summarizer", {"structured_decisions": "[]"})
check_update("operator", {"structured_decisions": "[]"}) # declared: ok
check_update("operator", None) # empty update: ok
def test_strict_state_is_identity_when_disabled(monkeypatch):
monkeypatch.delenv("ARTEMIS_STRICT_STATE", raising=False)
state = State.initial("g")
assert strict_state(state, "operator") is state
def test_strict_state_wraps_when_enabled(monkeypatch):
monkeypatch.setenv("ARTEMIS_STRICT_STATE", "1")
state = State.initial("g")
view = strict_state(state, "operator")
assert isinstance(view, StateView)
+10 -17
View File
@@ -54,9 +54,11 @@ SCOPED_PROMPT_FILES = [
"artemis/agents/validator/pixel_safety_net.md",
]
# Backtick-quoted identifiers in scoped prompts that are not tools: parameter names and
# note keys. Anything new landing here should be a conscious decision.
NON_TOOL_IDENTIFIERS = frozenset({"analysis", "task_plan"})
# Backtick-quoted identifiers in scoped prompts that are not tools: parameter names,
# note keys, and the availability-slot template helper rendered by
# apply_operator_prompt_contract. Anything new landing here should be a conscious
# decision.
NON_TOOL_IDENTIFIERS = frozenset({"analysis", "task_plan", "tool_enum"})
_TOOL_REF = re.compile(r"(?:`([a-z][a-z0-9_]{2,})`|\b([a-z][a-z0-9_]{2,})\()")
@@ -68,19 +70,12 @@ def _scoped_prompt_sources() -> dict[str, str]:
path = REPO_ROOT / rel
sources[path.name] = path.read_text(encoding="utf-8")
# The operator templates carry all operator prompt prose (teaching segments are
# gated in-template by availability slots; prompts.py holds no prompt text).
operator_json = REPO_ROOT / "artemis/agents/operator/operator.json"
for key, text in json.loads(operator_json.read_text(encoding="utf-8")).items():
sources[f"operator.json:{key}"] = text
# Only the prompt string constants from prompts.py -- scanning the module as text
# would pick up Python code (`len(`, `getattr(` ...).
from artemis.agents.operator import prompts as operator_prompts
for name in dir(operator_prompts):
value = getattr(operator_prompts, name)
if name.startswith("_") and isinstance(value, str) and len(value) > 80:
sources[f"prompts.py:{name}"] = value
return sources
@@ -102,8 +97,6 @@ def test_scan_finds_prompt_sources():
sources = _scoped_prompt_sources()
assert "flash_runner.md" in sources
assert "operator.json:main_template" in sources
assert "operator.json:troubleshooter_template" in sources
assert any(label.startswith("prompts.py:") for label in sources)
def test_every_prompt_tool_reference_is_classified():
@@ -194,7 +187,7 @@ def test_minimal_actuator_passes_validation():
def test_missing_required_action_fails_fast():
caps = (DEVICE_ACTIONS - {"click_sequence"})
caps = DEVICE_ACTIONS - {"click_sequence"}
with pytest.raises(ActuatorContractError, match="click_sequence"):
validate_actuator(_StubActuator(caps=caps))
@@ -258,9 +251,9 @@ def test_filter_drops_unimplemented_optional_actions():
def test_filter_passes_through_unclassified_agent_tools():
"""Agent-specific tools the manifest does not know must never be gated."""
actuator = _StubActuator(caps=REQUIRED_ACTIONS | INTERNAL_ACTIONS)
decls = _declarations("click_sequence", "reply_to_checker", "some_bespoke_tool")
decls = _declarations("click_sequence", "submit_findings", "some_bespoke_tool")
kept = [d.name for d in filter_declarations(decls, actuator, agent="operator")]
assert kept == ["click_sequence", "reply_to_checker", "some_bespoke_tool"]
assert kept == ["click_sequence", "submit_findings", "some_bespoke_tool"]
def test_filter_appends_extensions_for_targeted_agents_only():
+229
View File
@@ -0,0 +1,229 @@
# 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",
"report_failure_analysis",
]
# --- 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 the removal of the duplicate 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
registry (a direct-execution SDK surface, artemis/tools/mobile/exec_tools.py)
must never again carry a ``wait_for_delay`` whose unit disagrees with the
manifest's ``time_in_ms``.
"""
import artemis.tools.mobile.exec_tools # noqa: F401 - registration side effect
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
+39 -16
View File
@@ -185,7 +185,10 @@ def test_agent_config_loading():
assert agent_cfg.committee.debate_rounds == 2
assert agent_cfg.checker.enabled is False
assert agent_cfg.checker.max_iterations == 20
assert agent_cfg.checker.max_chat_rounds == 4
assert agent_cfg.checker.midway_checks is True
assert agent_cfg.checker.final_check is True
assert agent_cfg.checker.assert_failure_policy == "continue"
assert agent_cfg.checker.device_probes is True
assert agent_cfg.outputter.enabled is True
assert agent_cfg.outputter.force_synthesis is False
assert agent_cfg.flash.max_turns == 30
@@ -219,6 +222,7 @@ def test_output_config_and_recording():
def test_runtime_state_and_ipc(tmp_path, monkeypatch):
"""Test IPC port and LS address state helpers."""
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", MagicMock(side_effect=Exception("offline")))
ipc_state_file = tmp_path / ".artemis_ipc_port"
monkeypatch.setattr("artemis.config.runtime.get_ipc_port_file", lambda: ipc_state_file)
@@ -247,9 +251,9 @@ def test_runtime_state_and_ipc(tmp_path, monkeypatch):
def test_planner_validation_builder_and_milestones():
"""Test AgentConfigBuilder methods for planner validation and validate_milestones threshold."""
"""Test AgentConfigBuilder methods for planner validation and milestone drift detection."""
from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder
from artemis.graph.graph import validate_milestones
from artemis.utils.plan_grammar import milestones_changed, parse_plan
# Default builder inherits from artemis.jsonc (enabled=False, similarity_threshold=0.85)
builder = AgentConfigBuilder()
@@ -270,12 +274,13 @@ def test_planner_validation_builder_and_milestones():
cfg_disabled = AgentConfigBuilder().with_disable_planner_validation(True).build()
assert cfg_disabled.disable_planner_validation is True
# Test validate_milestones with custom thresholds
before = "- [ ] Tap Login button\n- [ ] Enter password"
after_minor = "- [ ] Tap the Login button\n- [ ] Enter password"
# Minor edit has ~0.94 similarity
assert validate_milestones(before, after_minor, similarity_threshold=0.85) is False
assert validate_milestones(before, after_minor, similarity_threshold=0.98) is True
# Milestone drift detection is threshold-free: any text change counts,
# status-only flips never do (see artemis.utils.plan_grammar)
before = parse_plan("- [ ] Tap Login button\n- [ ] Enter password")
after_minor = parse_plan("- [ ] Tap the Login button\n- [ ] Enter password")
after_status = parse_plan("- [x] Tap Login button\n- [/] Enter password")
assert milestones_changed(before, after_minor) is True
assert milestones_changed(before, after_status) is False
@pytest.mark.asyncio
@@ -333,22 +338,35 @@ def test_checker_builder_and_context_propagation():
from artemis.sdk.agent import Agent
from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder
# Default builder inherits from artemis.jsonc (enabled=False, max_iterations=20, max_chat_rounds=4)
# Default builder inherits from artemis.jsonc (enabled=False, max_iterations=20)
builder = AgentConfigBuilder()
cfg = builder.build()
assert cfg.disable_checker is True
assert cfg.checker_max_iterations == 20
assert cfg.checker_max_chat_rounds == 4
assert cfg.disable_midway_checks is False
assert cfg.disable_final_check is False
# Fluent enabling
# Fluent enabling with the new two-gate knobs
cfg_enabled = (
AgentConfigBuilder()
.with_checker(enabled=True, max_iterations=25, max_chat_rounds=5)
.with_checker(
enabled=True,
max_iterations=25,
midway_checks=True,
final_check=False,
checkpoint_max_repairs=1,
assert_failure_policy="halt",
device_probes=False,
)
.build()
)
assert cfg_enabled.disable_checker is False
assert cfg_enabled.checker_max_iterations == 25
assert cfg_enabled.checker_max_chat_rounds == 5
assert cfg_enabled.disable_midway_checks is False
assert cfg_enabled.disable_final_check is True
assert cfg_enabled.checkpoint_max_repairs == 1
assert cfg_enabled.assert_failure_policy == "halt"
assert cfg_enabled.disable_device_probes is True
# Fluent disabling
cfg_disabled = AgentConfigBuilder().with_disable_checker(True).build()
@@ -375,7 +393,13 @@ def test_checker_builder_and_context_propagation():
assert ctx.execution_setup is not None
assert ctx.execution_setup.disable_checker is False
assert ctx.execution_setup.checker_max_iterations == 25
assert ctx.execution_setup.checker_max_chat_rounds == 5
assert ctx.execution_setup.disable_final_check is True
assert ctx.execution_setup.checkpoint_max_repairs == 1
assert ctx.execution_setup.assert_failure_policy == "halt"
assert ctx.execution_setup.disable_device_probes is True
# Effective gate semantics: master alias off + individual gates
assert ctx.execution_setup.midway_checks_enabled is True
assert ctx.execution_setup.final_check_enabled is False
def test_explorer_builder_and_resolution(monkeypatch):
@@ -644,4 +668,3 @@ def test_admin_console_config_facade_backward_compatibility():
assert DB_PATH == ac.DB_PATH
assert TRACES_PATH == ac.TRACES_PATH
assert callable(init_ls_address)
+206
View File
@@ -0,0 +1,206 @@
"""Tests for the LLM gateway: complete(), classified recovery, and fallback."""
from types import SimpleNamespace
from langchain_core.messages import AIMessage, AIMessageChunk
import pytest
from artemis.llm.reliability import LLMExhaustedError, LLMPermanentError
from artemis.services import llm as llm_service
from artemis.services.llm import (
RobustChatModelWrapper,
acomplete,
acomplete_structured,
with_fallback,
)
@pytest.fixture(autouse=True)
def _isolate_gateway_state(monkeypatch, tmp_path):
llm_service._ENDPOINT_BREAKER._states.clear()
llm_service._NON_STREAMING_ENDPOINTS.clear()
monkeypatch.setattr(llm_service, "PAUSE_FILE", tmp_path / ".artemis_paused")
monkeypatch.setattr(
llm_service,
"retry_policy_for",
lambda category: SimpleNamespace(max_attempts=2, delay_for=lambda attempt: 0.0),
)
monkeypatch.setattr(llm_service.settings, "LLM_PAUSE_TIMEOUT_SECONDS", 1.0, raising=False)
yield
llm_service._ENDPOINT_BREAKER._states.clear()
llm_service._NON_STREAMING_ENDPOINTS.clear()
@pytest.mark.asyncio
async def test_mid_stream_failure_retries_without_duplicating_chunks():
class BreaksMidStream:
def __init__(self):
self.astream_calls = 0
async def astream(self, *args, **kwargs):
self.astream_calls += 1
if self.astream_calls == 1:
yield AIMessageChunk(content="Hello")
raise RuntimeError("503 service unavailable")
yield AIMessageChunk(content="Hello")
yield AIMessageChunk(content=" world")
base = BreaksMidStream()
wrapper = RobustChatModelWrapper(base)
result = await wrapper.complete([])
assert base.astream_calls == 2
# The partial first attempt was discarded entirely: no duplicated prefix.
assert result.content == "Hello world"
@pytest.mark.asyncio
async def test_auth_error_fails_fast_without_retry_or_pause(tmp_path):
class AuthFails:
def __init__(self):
self.astream_calls = 0
self.ainvoke_calls = 0
async def astream(self, *args, **kwargs):
self.astream_calls += 1
raise RuntimeError("401 unauthorized: invalid api key")
yield # pragma: no cover - makes this an async generator
async def ainvoke(self, *args, **kwargs):
self.ainvoke_calls += 1
return AIMessage(content="should never happen")
base = AuthFails()
wrapper = RobustChatModelWrapper(base)
with pytest.raises(LLMPermanentError):
await wrapper.complete([])
assert base.astream_calls == 1
assert base.ainvoke_calls == 0
assert not llm_service.PAUSE_FILE.exists()
@pytest.mark.asyncio
async def test_stream_unsupported_downgrades_loudly_and_is_remembered():
class NoStreaming:
def __init__(self):
self.astream_calls = 0
self.ainvoke_calls = 0
async def astream(self, *args, **kwargs):
self.astream_calls += 1
raise NotImplementedError("streaming is not supported")
yield # pragma: no cover
async def ainvoke(self, *args, **kwargs):
self.ainvoke_calls += 1
return AIMessage(content="ok")
base = NoStreaming()
wrapper = RobustChatModelWrapper(base)
first = await wrapper.complete([])
assert first.content == "ok"
assert wrapper._endpoint_key() in llm_service._NON_STREAMING_ENDPOINTS
second = await wrapper.complete([])
assert second.content == "ok"
# The capability was cached: streaming is not probed again.
assert base.astream_calls == 1
assert base.ainvoke_calls == 2
@pytest.mark.asyncio
async def test_exhausted_retries_pause_then_raise_on_deadline():
class AlwaysUnavailable:
def __init__(self):
self.calls = 0
async def astream(self, *args, **kwargs):
self.calls += 1
raise RuntimeError("503 service unavailable")
yield # pragma: no cover
wrapper = RobustChatModelWrapper(AlwaysUnavailable())
with pytest.raises(LLMExhaustedError):
await wrapper.complete([])
# The pause file was written for the pause window, then cleaned up.
assert not llm_service.PAUSE_FILE.exists()
@pytest.mark.asyncio
async def test_with_fallback_takes_over_without_pausing():
class AlwaysUnavailable:
async def astream(self, *args, **kwargs):
raise RuntimeError("503 service unavailable")
yield # pragma: no cover
wrapper = RobustChatModelWrapper(AlwaysUnavailable())
async def fallback_call():
return AIMessage(content="fallback answer")
result = await with_fallback(
main_call=lambda: wrapper.complete([]),
fallback_call=fallback_call,
)
assert result.content == "fallback answer"
# Because a fallback existed, exhaustion handed over instead of pausing.
assert not llm_service.PAUSE_FILE.exists()
@pytest.mark.asyncio
async def test_with_fallback_refuses_to_hide_bad_requests():
class CodedError(Exception):
code = 400
calls = {"fallback": 0}
async def main_call():
raise CodedError("malformed tool schema")
async def fallback_call():
calls["fallback"] += 1
return "hidden bug"
with pytest.raises(CodedError):
await with_fallback(main_call=main_call, fallback_call=fallback_call)
assert calls["fallback"] == 0
@pytest.mark.asyncio
async def test_acomplete_structured_corrective_reask():
class CorrectsItself:
def __init__(self):
self.calls = []
async def ainvoke(self, messages, **kwargs):
self.calls.append(messages)
if len(self.calls) == 1:
return AIMessage(content="oops, here is prose instead of JSON")
return AIMessage(content='{"a": 1}')
base = CorrectsItself()
result = await acomplete_structured(base, [])
assert result == {"a": 1}
assert len(base.calls) == 2
correction_text = str(base.calls[1][-1].content)
assert "could not be parsed" in correction_text
@pytest.mark.asyncio
async def test_acomplete_compat_path_for_plain_models():
class PlainModel:
async def astream(self, *args, **kwargs):
yield AIMessageChunk(content="a")
yield AIMessageChunk(content="b")
result = await acomplete(PlainModel(), [])
assert result.content == "ab"
class InvokeOnly:
async def ainvoke(self, *args, **kwargs):
return AIMessage(content="direct")
result = await acomplete(InvokeOnly(), [])
assert result.content == "direct"
+68
View File
@@ -0,0 +1,68 @@
"""Tests for the generic LLM failure taxonomy and circuit breaker."""
from artemis.llm.reliability import (
CircuitBreaker,
FailureCategory,
classify_failure,
retry_policy_for,
)
def test_classifier_categories_and_recovery_decisions():
rate = classify_failure(RuntimeError("429 rate limit exceeded"))
assert rate.category is FailureCategory.RATE_LIMIT
assert rate.retryable and rate.should_fallback
outage = classify_failure(RuntimeError("503 service unavailable"))
assert outage.category is FailureCategory.PROVIDER_UNAVAILABLE
assert outage.retryable
timeout = classify_failure(TimeoutError("request timed out"))
assert timeout.category is FailureCategory.TIMEOUT
assert timeout.retryable
conn = classify_failure(ConnectionError("connection reset by peer"))
assert conn.category is FailureCategory.CONNECTION
assert conn.retryable
auth = classify_failure(RuntimeError("401 unauthorized: invalid api key"))
assert auth.category is FailureCategory.AUTHENTICATION
assert not auth.retryable
assert auth.should_fallback # A fallback endpoint may hold a working key.
class CodedError(Exception):
code = 400
bad = classify_failure(CodedError("invalid request payload"))
assert bad.category is FailureCategory.BAD_REQUEST
assert not bad.retryable
assert not bad.should_fallback # Falling back would only hide the bug.
unknown = classify_failure(RuntimeError("something odd"))
assert unknown.category is FailureCategory.UNKNOWN
assert unknown.retryable
def test_non_retryable_categories_get_single_attempt():
assert retry_policy_for(FailureCategory.AUTHENTICATION).max_attempts == 1
assert retry_policy_for(FailureCategory.BAD_REQUEST).max_attempts == 1
assert retry_policy_for(FailureCategory.RATE_LIMIT).max_attempts > 1
def test_breaker_only_trips_on_transient_categories():
breaker = CircuitBreaker(threshold=2, cooldown_seconds=10.0)
auth = classify_failure(RuntimeError("401 unauthorized"))
for _ in range(5):
breaker.record_failure("m", auth, now=1.0)
assert breaker.allow("m", now=1.5)
outage = classify_failure(RuntimeError("503 unavailable"))
breaker.record_failure("m", outage, now=2.0)
breaker.record_failure("m", outage, now=3.0)
assert not breaker.allow("m", now=4.0)
assert breaker.open_remaining("m", now=4.0) > 0
# Cooldown elapsed: half-open admits exactly one trial.
assert breaker.allow("m", now=13.5)
assert not breaker.allow("m", now=13.6)
breaker.record_success("m")
assert breaker.allow("m", now=13.7)
+73
View File
@@ -0,0 +1,73 @@
"""Tests for the unified structured-output parsing pipeline."""
from pydantic import BaseModel
from artemis.llm.structured import (
ParseFailure,
content_to_text,
extract_json_candidate,
parse_structured,
repair_json,
)
def test_parses_fenced_json_block():
text = 'Here is my plan.\n```json\n{"name": "click", "args": {"x": 1}}\n```\nDone.'
result = parse_structured(text)
assert result == {"name": "click", "args": {"x": 1}}
def test_parses_bare_json_embedded_in_prose():
text = 'Sure! {"is_present": true, "confidence": 0.9} — hope that helps.'
result = parse_structured(text)
assert result == {"is_present": True, "confidence": 0.9}
def test_balanced_extraction_ignores_braces_inside_strings():
text = '{"msg": "curly } inside", "n": [1, 2]}'
assert extract_json_candidate(text) == text
def test_repairs_trailing_commas_and_comments():
noisy = '{\n // a comment\n "a": 1, /* block */\n "b": [1, 2,],\n}'
assert parse_structured(noisy) == {"a": 1, "b": [1, 2]}
# Repair must not touch string contents.
assert repair_json('{"s": "a, } // not a comment"}') == '{"s": "a, } // not a comment"}'
def test_strips_thinking_tags_before_extraction():
text = "<thinking>{not json}</thinking>\n```json\n[1, 2, 3]\n```"
assert parse_structured(text) == [1, 2, 3]
def test_failure_is_typed_never_raw_text():
result = parse_structured("no structured payload here at all")
assert isinstance(result, ParseFailure)
assert "no JSON" in result.error
garbage = parse_structured("```json\n{definitely: not: json}\n```")
assert isinstance(garbage, ParseFailure)
def test_schema_validation():
class Verdict(BaseModel):
is_present: bool
confidence: float
ok = parse_structured('{"is_present": false, "confidence": 0.4}', schema=Verdict)
assert isinstance(ok, Verdict) and ok.confidence == 0.4
bad = parse_structured('{"is_present": "definitely"}', schema=Verdict)
assert isinstance(bad, ParseFailure)
assert "schema validation failed" in bad.error
def test_content_to_text_flattens_blocks_and_skips_thinking():
blocks = [
{"type": "thinking", "thinking": "hidden"},
{"type": "text", "text": "hello "},
"raw",
{"type": "text", "text": "world"},
]
assert content_to_text(blocks) == "hello rawworld"
assert content_to_text("plain") == "plain"
+1 -1
View File
@@ -17,7 +17,7 @@
import pytest
from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.tools.base import ToolRegistry
import artemis.tools.actions.device_actions # noqa: F401 - Register action tools
import artemis.tools.mobile.exec_tools # noqa: F401 - Register action tools
@pytest.mark.asyncio
+8 -16
View File
@@ -13,9 +13,8 @@
# limitations under the License.
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
@@ -28,7 +27,7 @@ from artemis.tools.scratchpad import (
append_note,
append_note_wrapper,
)
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -108,11 +107,9 @@ async def test_append_note_callable_execution(mock_ctx, tmp_path):
@pytest.mark.asyncio
async def test_append_note_with_state_command(mock_ctx, tmp_path):
"""Verify AppendNoteTool.execute with state returns Command updating VALIDATOR_MESSAGES_KEY."""
async def test_append_note_with_state_tool_message(mock_ctx, tmp_path):
"""Verify AppendNoteTool.execute with state returns a ToolMessage directly."""
mock_state = MagicMock(spec=State)
mock_state.current_agent = "operator"
mock_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update)
result = await append_note.execute(
ctx=mock_ctx,
@@ -122,15 +119,10 @@ async def test_append_note_with_state_command(mock_ctx, tmp_path):
tool_call_id="call_append_999",
)
assert isinstance(result, Command)
assert VALIDATOR_MESSAGES_KEY in result.update
tool_messages = result.update[VALIDATOR_MESSAGES_KEY]
assert len(tool_messages) == 1
assert tool_messages[0].tool_call_id == "call_append_999"
assert tool_messages[0].content == "Successfully appended note to state_append_note.md."
assert tool_messages[0].status == "success"
mock_state.asanitize_update.assert_called_once()
assert isinstance(result, ToolMessage)
assert result.tool_call_id == "call_append_999"
assert result.content == "Successfully appended note to state_append_note.md."
assert result.status == "success"
note_path = Path(tmp_path) / "notes" / "state_append_note.md"
assert note_path.exists()
+7 -17
View File
@@ -14,7 +14,6 @@
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
@@ -27,7 +26,7 @@ from artemis.tools.mobile.exec_tools import (
click_wrapper,
get_click_tool,
)
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -50,13 +49,7 @@ def mock_driver():
@pytest.fixture
def mock_state():
state = MagicMock(spec=State)
async def _mock_asanitize_update(ctx, update, agent):
return update
state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update)
return state
return MagicMock(spec=State)
def test_click_tool_subclass_and_registry():
@@ -108,7 +101,7 @@ async def test_click_direct_execution_with_ctx(mock_ctx):
@pytest.mark.asyncio
async def test_click_with_state_command(mock_ctx, mock_state):
"""Verify ClickTool returns Command when state is provided."""
"""Verify ClickTool returns ToolMessage when state is provided."""
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
mock_controller_inst = MagicMock()
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
@@ -120,13 +113,10 @@ async def test_click_with_state_command(mock_ctx, mock_state):
tool_call_id="call_click_1",
state=mock_state,
)
assert isinstance(cmd, Command)
assert VALIDATOR_MESSAGES_KEY in cmd.update
messages = cmd.update[VALIDATOR_MESSAGES_KEY]
assert len(messages) == 1
assert messages[0].tool_call_id == "call_click_1"
assert messages[0].status == "success"
assert "Clicked at [100, 200]" in messages[0].content
assert isinstance(cmd, ToolMessage)
assert cmd.tool_call_id == "call_click_1"
assert cmd.status == "success"
assert "Clicked at [100, 200]" in cmd.content
@pytest.mark.asyncio
-5
View File
@@ -62,8 +62,6 @@ class TestCommitteeTool(unittest.IsolatedAsyncioTestCase):
mock_state.device_date = None
mock_state.structured_decisions = None
mock_state.complete_subgoals_by_ids = []
mock_state.validator_messages = []
mock_state.operator_replan_reason = "Mock Immediate Replan Reason"
# Mock LLM responses for the members
mock_llm_pl = MagicMock()
@@ -165,9 +163,6 @@ class TestCommitteeTool(unittest.IsolatedAsyncioTestCase):
"Timeout waiting for element", content
) # Check failed history is in blackboard
self.assertIn("Test Goal", content) # Check initial goal is in blackboard
self.assertIn(
"Mock Immediate Replan Reason", content
) # Check current replan reason is in blackboard
finally:
# Clean up temp directory
+7 -17
View File
@@ -14,7 +14,6 @@
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
@@ -27,7 +26,7 @@ from artemis.tools.mobile.exec_tools import (
input_text,
input_text_wrapper,
)
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -51,13 +50,7 @@ def mock_driver():
@pytest.fixture
def mock_state():
state = MagicMock(spec=State)
async def _mock_asanitize_update(ctx, update, agent):
return update
state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update)
return state
return MagicMock(spec=State)
def test_input_text_tool_subclass_and_registry():
@@ -120,7 +113,7 @@ async def test_input_text_direct_execution_with_ctx(mock_ctx):
@pytest.mark.asyncio
async def test_input_text_with_state_command(mock_ctx, mock_state):
"""Verify InputTextTool returns Command when state is provided."""
"""Verify InputTextTool returns ToolMessage when state is provided."""
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
mock_controller_inst = MagicMock()
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
@@ -135,13 +128,10 @@ async def test_input_text_with_state_command(mock_ctx, mock_state):
tool_call_id="call_it_1",
state=mock_state,
)
assert isinstance(cmd, Command)
assert VALIDATOR_MESSAGES_KEY in cmd.update
messages = cmd.update[VALIDATOR_MESSAGES_KEY]
assert len(messages) == 1
assert messages[0].tool_call_id == "call_it_1"
assert messages[0].status == "success"
assert "Typed 'query'" in messages[0].content
assert isinstance(cmd, ToolMessage)
assert cmd.tool_call_id == "call_it_1"
assert cmd.status == "success"
assert "Typed 'query'" in cmd.content
@pytest.mark.asyncio
+7 -17
View File
@@ -14,7 +14,6 @@
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
@@ -27,7 +26,7 @@ from artemis.tools.mobile.launch_app import (
launch_app,
launch_app_wrapper,
)
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -47,13 +46,7 @@ def mock_driver():
@pytest.fixture
def mock_state():
state = MagicMock(spec=State)
async def _mock_asanitize_update(ctx, update, agent):
return update
state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update)
return state
return MagicMock(spec=State)
def test_launch_app_tool_subclass_and_registry():
@@ -144,7 +137,7 @@ async def test_launch_app_execution_failure(mock_ctx):
@pytest.mark.asyncio
async def test_launch_app_with_state_command(mock_ctx, mock_state):
"""Verify LaunchAppTool returns Command when state is provided."""
"""Verify LaunchAppTool returns ToolMessage when state is provided."""
with (
patch(
"artemis.tools.mobile.launch_app.find_package",
@@ -163,13 +156,10 @@ async def test_launch_app_with_state_command(mock_ctx, mock_state):
tool_call_id="call_la_1",
state=mock_state,
)
assert isinstance(cmd, Command)
assert VALIDATOR_MESSAGES_KEY in cmd.update
messages = cmd.update[VALIDATOR_MESSAGES_KEY]
assert len(messages) == 1
assert messages[0].tool_call_id == "call_la_1"
assert messages[0].status == "success"
assert "App 'State App' launched successfully." in messages[0].content
assert isinstance(cmd, ToolMessage)
assert cmd.tool_call_id == "call_la_1"
assert cmd.status == "success"
assert "App 'State App' launched successfully." in cmd.content
@pytest.mark.asyncio
+9 -17
View File
@@ -12,9 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
@@ -28,7 +27,7 @@ from artemis.tools.scratchpad import (
list_notes_wrapper,
)
from artemis.utils.notes import save_note_content
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -95,13 +94,11 @@ async def test_list_notes_callable_execution(mock_ctx, tmp_path):
@pytest.mark.asyncio
async def test_list_notes_with_state_command(mock_ctx, tmp_path):
"""Verify ListNotesTool.execute with state returns Command updating VALIDATOR_MESSAGES_KEY."""
async def test_list_notes_with_state_tool_message(mock_ctx, tmp_path):
"""Verify ListNotesTool.execute with state returns a ToolMessage directly."""
save_note_content(tmp_path, "state_note", "content")
mock_state = MagicMock(spec=State)
mock_state.current_agent = "operator"
mock_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update)
result = await list_notes.execute(
ctx=mock_ctx,
@@ -109,16 +106,11 @@ async def test_list_notes_with_state_command(mock_ctx, tmp_path):
tool_call_id="call_list_999",
)
assert isinstance(result, Command)
assert VALIDATOR_MESSAGES_KEY in result.update
tool_messages = result.update[VALIDATOR_MESSAGES_KEY]
assert len(tool_messages) == 1
assert tool_messages[0].tool_call_id == "call_list_999"
assert "Here are all the notes:" in tool_messages[0].content
assert "- state_note (1 lines)" in tool_messages[0].content
assert tool_messages[0].status == "success"
mock_state.asanitize_update.assert_called_once()
assert isinstance(result, ToolMessage)
assert result.tool_call_id == "call_list_999"
assert "Here are all the notes:" in result.content
assert "- state_note (1 lines)" in result.content
assert result.status == "success"
@pytest.mark.asyncio
+7 -17
View File
@@ -14,7 +14,6 @@
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
@@ -27,7 +26,7 @@ from artemis.tools.mobile.exec_tools import (
long_press,
long_press_wrapper,
)
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -50,13 +49,7 @@ def mock_driver():
@pytest.fixture
def mock_state():
state = MagicMock(spec=State)
async def _mock_asanitize_update(ctx, update, agent):
return update
state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update)
return state
return MagicMock(spec=State)
def test_long_press_tool_subclass_and_registry():
@@ -111,7 +104,7 @@ async def test_long_press_direct_execution_with_ctx(mock_ctx):
@pytest.mark.asyncio
async def test_long_press_with_state_command(mock_ctx, mock_state):
"""Verify LongPressTool returns Command when state is provided."""
"""Verify LongPressTool returns ToolMessage when state is provided."""
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
mock_controller_inst = MagicMock()
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
@@ -124,13 +117,10 @@ async def test_long_press_with_state_command(mock_ctx, mock_state):
tool_call_id="call_lp_1",
state=mock_state,
)
assert isinstance(cmd, Command)
assert VALIDATOR_MESSAGES_KEY in cmd.update
messages = cmd.update[VALIDATOR_MESSAGES_KEY]
assert len(messages) == 1
assert messages[0].tool_call_id == "call_lp_1"
assert messages[0].status == "success"
assert "Long pressed at [100, 200]" in messages[0].content
assert isinstance(cmd, ToolMessage)
assert cmd.tool_call_id == "call_lp_1"
assert cmd.status == "success"
assert "Long pressed at [100, 200]" in cmd.content
@pytest.mark.asyncio
+10 -3
View File
@@ -17,6 +17,8 @@
import json
from unittest.mock import AsyncMock, MagicMock, patch
from langchain_core.messages import ToolMessage
from artemis.context import ArtemisContext
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
@@ -110,13 +112,12 @@ async def test_object_detection_direct_execution_success():
@pytest.mark.asyncio
async def test_operator_object_detection_direct_execution_with_state():
"""Verify operator_object_detection reads screenshot from state."""
"""Verify operator_object_detection reads screenshot from state and returns a ToolMessage."""
mock_ctx = MagicMock(spec=ArtemisContext)
mock_ctx.data_engine = None
mock_state = MagicMock(spec=State)
mock_state.latest_screenshot = "/path/to/state_screen.jpg"
mock_state.asanitize_update = None # Non-langgraph direct call
expected_result = {
"detected": [{"label": "search bar", "point": [500, 100]}],
@@ -137,7 +138,13 @@ async def test_operator_object_detection_direct_execution_with_state():
# Verify it used the latest_screenshot from state
call_args = mock_run.call_args[0]
assert call_args[1] == "/path/to/state_screen.jpg"
assert json.loads(result) == expected_result
# With state, the tool returns a ToolMessage directly
assert isinstance(result, ToolMessage)
assert result.status == "success"
assert "Object Detection successful." in result.content
json_payload = result.content.split("Results:\n", 1)[1]
assert json.loads(json_payload) == expected_result
@pytest.mark.asyncio
@@ -27,7 +27,6 @@ async def test_object_detection_trace():
mock_state = MagicMock(spec=State)
mock_state.latest_screenshot = "/path/to/screenshot.jpg"
mock_state.asanitize_update = AsyncMock(return_value={"status": "success"})
# We need to mock the decorator in object_detection_tool.py if we want to avoid it calling real get_llm
# Or we can just mock get_llm.
+14 -23
View File
@@ -15,7 +15,6 @@
import base64
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
@@ -30,7 +29,7 @@ from artemis.tools.mobile.ocr import (
ocr_recognition,
ocr_recognition_wrapper,
)
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -61,11 +60,6 @@ def mock_state(tmp_path):
state = MagicMock(spec=State)
state.latest_screenshot = str(screenshot_file)
async def _mock_asanitize_update(ctx, update, agent):
return update
state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update)
return state
@@ -125,8 +119,9 @@ async def test_ocr_direct_execution_with_state(mock_ctx, tmp_path):
return_value=mock_results,
):
result = await ocr_recognition.execute(ctx=mock_ctx, state=simple_state)
assert "OCR Recognition successful." in result
assert "Submit" in result
assert isinstance(result, ToolMessage)
assert "OCR Recognition successful." in result.content
assert "Submit" in result.content
@pytest.mark.asyncio
@@ -164,12 +159,13 @@ async def test_ocr_no_text_detected(mock_ctx, tmp_path):
return_value=[],
):
result = await ocr_recognition.execute(ctx=mock_ctx, state=simple_state)
assert "No text detected on the screen." in result
assert isinstance(result, ToolMessage)
assert "No text detected on the screen." in result.content
@pytest.mark.asyncio
async def test_ocr_with_state_command(mock_ctx, mock_state):
"""Verify OcrRecognitionTool returns Command when state is provided."""
"""Verify OcrRecognitionTool returns ToolMessage when state is provided."""
mock_results = [{"text": "Hello", "position": [{"x": 100, "y": 100}]}]
with patch(
"artemis.tools.mobile.ocr.perform_ocr",
@@ -181,13 +177,10 @@ async def test_ocr_with_state_command(mock_ctx, mock_state):
tool_call_id="call_ocr_1",
state=mock_state,
)
assert isinstance(cmd, Command)
assert VALIDATOR_MESSAGES_KEY in cmd.update
messages = cmd.update[VALIDATOR_MESSAGES_KEY]
assert len(messages) == 1
assert messages[0].tool_call_id == "call_ocr_1"
assert messages[0].status == "success"
assert "Hello" in messages[0].content
assert isinstance(cmd, ToolMessage)
assert cmd.tool_call_id == "call_ocr_1"
assert cmd.status == "success"
assert "Hello" in cmd.content
@pytest.mark.asyncio
@@ -195,17 +188,15 @@ async def test_ocr_failure_missing_screenshot(mock_ctx):
"""Verify error handling when screenshot path is missing."""
empty_state = MagicMock(spec=State)
empty_state.latest_screenshot = None
empty_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update)
cmd = await ocr_recognition.execute(
ctx=mock_ctx,
tool_call_id="call_ocr_err",
state=empty_state,
)
assert isinstance(cmd, Command)
messages = cmd.update[VALIDATOR_MESSAGES_KEY]
assert messages[0].status == "error"
assert "OCR Recognition failed" in messages[0].content
assert isinstance(cmd, ToolMessage)
assert cmd.status == "error"
assert "OCR Recognition failed" in cmd.content
@pytest.mark.asyncio
+7 -17
View File
@@ -14,7 +14,6 @@
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
@@ -27,7 +26,7 @@ from artemis.tools.mobile.exec_tools import (
press_key,
press_key_wrapper,
)
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -47,13 +46,7 @@ def mock_driver():
@pytest.fixture
def mock_state():
state = MagicMock(spec=State)
async def _mock_asanitize_update(ctx, update, agent):
return update
state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update)
return state
return MagicMock(spec=State)
def test_press_key_tool_subclass_and_registry():
@@ -124,7 +117,7 @@ async def test_press_key_direct_execution_with_ctx(mock_ctx):
@pytest.mark.asyncio
async def test_press_key_with_state_command(mock_ctx, mock_state):
"""Verify PressKeyTool returns Command when state is provided."""
"""Verify PressKeyTool returns ToolMessage when state is provided."""
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
mock_controller_inst = MagicMock()
mock_controller_inst.go_home = AsyncMock(return_value=True)
@@ -136,13 +129,10 @@ async def test_press_key_with_state_command(mock_ctx, mock_state):
tool_call_id="call_pk_1",
state=mock_state,
)
assert isinstance(cmd, Command)
assert VALIDATOR_MESSAGES_KEY in cmd.update
messages = cmd.update[VALIDATOR_MESSAGES_KEY]
assert len(messages) == 1
assert messages[0].tool_call_id == "call_pk_1"
assert messages[0].status == "success"
assert "Pressed key 'HOME' successfully." in messages[0].content
assert isinstance(cmd, ToolMessage)
assert cmd.tool_call_id == "call_pk_1"
assert cmd.status == "success"
assert "Pressed key 'HOME' successfully." in cmd.content
@pytest.mark.asyncio
+9 -17
View File
@@ -12,9 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
@@ -28,7 +27,7 @@ from artemis.tools.scratchpad import (
read_note_wrapper,
)
from artemis.utils.notes import save_note_content
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -106,13 +105,11 @@ async def test_read_note_callable_execution(mock_ctx, tmp_path):
@pytest.mark.asyncio
async def test_read_note_with_state_command(mock_ctx, tmp_path):
"""Verify ReadNoteTool.execute with state returns Command updating VALIDATOR_MESSAGES_KEY."""
async def test_read_note_with_state_tool_message(mock_ctx, tmp_path):
"""Verify ReadNoteTool.execute with state returns a ToolMessage directly."""
save_note_content(tmp_path, "state_sample", "state note content")
mock_state = MagicMock(spec=State)
mock_state.current_agent = "operator"
mock_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update)
result = await read_note.execute(
ctx=mock_ctx,
@@ -121,16 +118,11 @@ async def test_read_note_with_state_command(mock_ctx, tmp_path):
tool_call_id="call_read_999",
)
assert isinstance(result, Command)
assert VALIDATOR_MESSAGES_KEY in result.update
tool_messages = result.update[VALIDATOR_MESSAGES_KEY]
assert len(tool_messages) == 1
assert tool_messages[0].tool_call_id == "call_read_999"
assert "Successfully read note 'state_sample'." in tool_messages[0].content
assert "state note content" in tool_messages[0].content
assert tool_messages[0].status == "success"
mock_state.asanitize_update.assert_called_once()
assert isinstance(result, ToolMessage)
assert result.tool_call_id == "call_read_999"
assert "Successfully read note 'state_sample'." in result.content
assert "state note content" in result.content
assert result.status == "success"
@pytest.mark.asyncio
+8 -16
View File
@@ -13,9 +13,8 @@
# limitations under the License.
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
@@ -28,7 +27,7 @@ from artemis.tools.scratchpad import (
save_note,
save_note_wrapper,
)
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -98,11 +97,9 @@ async def test_save_note_callable_execution(mock_ctx, tmp_path):
@pytest.mark.asyncio
async def test_save_note_with_state_command(mock_ctx, tmp_path):
"""Verify SaveNoteTool.execute with state returns Command updating VALIDATOR_MESSAGES_KEY."""
async def test_save_note_with_state_tool_message(mock_ctx, tmp_path):
"""Verify SaveNoteTool.execute with state returns a ToolMessage directly."""
mock_state = MagicMock(spec=State)
mock_state.current_agent = "operator"
mock_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update)
result = await save_note.execute(
ctx=mock_ctx,
@@ -112,15 +109,10 @@ async def test_save_note_with_state_command(mock_ctx, tmp_path):
tool_call_id="call_save_999",
)
assert isinstance(result, Command)
assert VALIDATOR_MESSAGES_KEY in result.update
tool_messages = result.update[VALIDATOR_MESSAGES_KEY]
assert len(tool_messages) == 1
assert tool_messages[0].tool_call_id == "call_save_999"
assert tool_messages[0].content == "Successfully saved note to state_note.md."
assert tool_messages[0].status == "success"
mock_state.asanitize_update.assert_called_once()
assert isinstance(result, ToolMessage)
assert result.tool_call_id == "call_save_999"
assert result.content == "Successfully saved note to state_note.md."
assert result.status == "success"
note_path = Path(tmp_path) / "notes" / "state_note.md"
assert note_path.exists()
+7 -17
View File
@@ -14,7 +14,6 @@
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
@@ -27,7 +26,7 @@ from artemis.tools.mobile.exec_tools import (
swipe,
swipe_wrapper,
)
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -51,13 +50,7 @@ def mock_driver():
@pytest.fixture
def mock_state():
state = MagicMock(spec=State)
async def _mock_asanitize_update(ctx, update, agent):
return update
state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update)
return state
return MagicMock(spec=State)
def test_swipe_tool_subclass_and_registry():
@@ -131,7 +124,7 @@ async def test_swipe_direct_execution_with_ctx_coords(mock_ctx):
@pytest.mark.asyncio
async def test_swipe_with_state_command(mock_ctx, mock_state):
"""Verify SwipeTool returns Command when state is provided."""
"""Verify SwipeTool returns ToolMessage when state is provided."""
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
mock_controller_inst = MagicMock()
mock_controller_inst.swipe_coords = AsyncMock(return_value=None)
@@ -143,13 +136,10 @@ async def test_swipe_with_state_command(mock_ctx, mock_state):
tool_call_id="call_sw_1",
state=mock_state,
)
assert isinstance(cmd, Command)
assert VALIDATOR_MESSAGES_KEY in cmd.update
messages = cmd.update[VALIDATOR_MESSAGES_KEY]
assert len(messages) == 1
assert messages[0].tool_call_id == "call_sw_1"
assert messages[0].status == "success"
assert "Swipe completed successfully." in messages[0].content
assert isinstance(cmd, ToolMessage)
assert cmd.tool_call_id == "call_sw_1"
assert cmd.status == "success"
assert "Swipe completed successfully." in cmd.content
@pytest.mark.asyncio
+8 -16
View File
@@ -13,9 +13,8 @@
# limitations under the License.
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
@@ -29,7 +28,7 @@ from artemis.tools.scratchpad import (
update_note_wrapper,
)
from artemis.utils.notes import save_note_content
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -123,13 +122,11 @@ async def test_update_note_callable_execution(mock_ctx, tmp_path):
@pytest.mark.asyncio
async def test_update_note_with_state_command(mock_ctx, tmp_path):
"""Verify UpdateNoteTool.execute with state returns Command updating VALIDATOR_MESSAGES_KEY."""
async def test_update_note_with_state_tool_message(mock_ctx, tmp_path):
"""Verify UpdateNoteTool.execute with state returns a ToolMessage directly."""
save_note_content(tmp_path, "state_plan", "- [ ] Step 1")
mock_state = MagicMock(spec=State)
mock_state.current_agent = "operator"
mock_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update)
result = await update_note.execute(
ctx=mock_ctx,
@@ -140,15 +137,10 @@ async def test_update_note_with_state_command(mock_ctx, tmp_path):
tool_call_id="call_update_999",
)
assert isinstance(result, Command)
assert VALIDATOR_MESSAGES_KEY in result.update
tool_messages = result.update[VALIDATOR_MESSAGES_KEY]
assert len(tool_messages) == 1
assert tool_messages[0].tool_call_id == "call_update_999"
assert tool_messages[0].content == "Successfully updated note 'state_plan'."
assert tool_messages[0].status == "success"
mock_state.asanitize_update.assert_called_once()
assert isinstance(result, ToolMessage)
assert result.tool_call_id == "call_update_999"
assert result.content == "Successfully updated note 'state_plan'."
assert result.status == "success"
note_path = Path(tmp_path) / "notes" / "state_plan.md"
assert note_path.read_text(encoding="utf-8") == "- [x] Step 1"
+11 -23
View File
@@ -14,7 +14,6 @@
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
@@ -27,7 +26,7 @@ from artemis.tools.video_tool import (
get_video_analyzer_tool_pure,
video_analyzer,
)
from langgraph.types import Command
from langchain_core.messages import ToolMessage
import pytest
@@ -38,14 +37,8 @@ def mock_ctx():
@pytest.fixture
def mock_state(mock_ctx):
state = MagicMock(spec=State)
async def _mock_asanitize_update(ctx, update, agent):
return update
state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update)
return state
def mock_state():
return MagicMock(spec=State)
def test_video_analyzer_tool_subclass_and_registry():
@@ -104,7 +97,7 @@ async def test_video_analyzer_direct_execution_failed(mock_ctx):
@pytest.mark.asyncio
async def test_video_analyzer_with_state_command(mock_ctx, mock_state):
"""Verify execute returns a Command with ToolMessage when state is provided."""
"""Verify execute returns a ToolMessage when state is provided."""
with patch(
"artemis.tools.video_tool.VideoAnalyzer.run",
new_callable=AsyncMock,
@@ -117,13 +110,10 @@ async def test_video_analyzer_with_state_command(mock_ctx, mock_state):
tool_call_id="call_video_123",
state=mock_state,
)
assert isinstance(cmd, Command)
assert VALIDATOR_MESSAGES_KEY in cmd.update
messages = cmd.update[VALIDATOR_MESSAGES_KEY]
assert len(messages) == 1
assert messages[0].tool_call_id == "call_video_123"
assert messages[0].content == "Action verified in video"
assert messages[0].status == "success"
assert isinstance(cmd, ToolMessage)
assert cmd.tool_call_id == "call_video_123"
assert cmd.content == "Action verified in video"
assert cmd.status == "success"
@pytest.mark.asyncio
@@ -150,11 +140,9 @@ async def test_video_analyzer_exception_handling(mock_ctx, mock_state):
tool_call_id="call_err",
state=mock_state,
)
assert isinstance(cmd, Command)
assert VALIDATOR_MESSAGES_KEY in cmd.update
messages = cmd.update[VALIDATOR_MESSAGES_KEY]
assert messages[0].status == "error"
assert "Error running video analyzer" in messages[0].content
assert isinstance(cmd, ToolMessage)
assert cmd.status == "error"
assert "Error running video analyzer" in cmd.content
@pytest.mark.asyncio
+241
View File
@@ -0,0 +1,241 @@
# 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.
import json
from pathlib import Path
from artemis.utils.plan_grammar import (
CONTINUOUS_LOOP_TAG,
PLAN_GRAMMAR_SPEC,
check_items_changed,
milestones_changed,
missing_check_items,
new_top_level_completions,
parse_plan,
render_plan_grammar_spec,
restore_missing_check_items,
subgoal_hash,
unintended_milestone_edits,
)
PLAN = """# Task Plan
Some free-form context the parser must ignore.
- [x] Open the email app
- [/] [Loop] Inspect candidates (Exit: all inspected; Interval: n/a)
- [x] Candidate #1
- [/] Candidate #2
- [ ] [Loop:continuous] Monitor inbox (Interval: every 5 minutes)
- [!] Blocked milestone
"""
def test_parse_plan_machine_channel_only():
snap = parse_plan(PLAN)
assert len(snap.items) == 6
assert len(snap.top_level) == 4
assert snap.milestone_texts[0] == "Open the email app"
top = snap.top_level
assert top[0].is_done
assert top[1].is_active and top[1].is_loop and not top[1].is_continuous
assert top[2].is_pending and top[2].is_loop and top[2].is_continuous
assert top[3].status == "!"
sub = [i for i in snap.items if not i.is_top_level]
assert all(i.indent == 2 for i in sub)
def test_parse_plan_empty_and_none():
assert parse_plan(None).items == ()
assert parse_plan("").items == ()
assert not parse_plan("just prose, no checkboxes").has_top_level
def test_snapshot_termination_properties():
assert not parse_plan(PLAN).all_top_level_done
done = "- [x] A\n- [x] B"
assert parse_plan(done).all_top_level_done
assert parse_plan("- [x] A\n- [x] [Loop:continuous] M").continuous_top_level
def test_active_item_navigation():
snap = parse_plan(PLAN)
assert snap.first_active().text.startswith("[Loop] Inspect")
assert snap.last_active().text == "Candidate #2"
parent = snap.parent_of(snap.last_active())
assert parent is not None and parent.text.startswith("[Loop] Inspect")
def test_milestones_changed_only_on_text():
before = parse_plan("- [ ] A\n- [ ] B")
status_flip = parse_plan("- [x] A\n- [/] B")
reworded = parse_plan("- [ ] A\n- [ ] B improved")
added = parse_plan("- [ ] A\n- [ ] B\n- [ ] C")
assert not milestones_changed(before, status_flip)
assert milestones_changed(before, reworded)
assert milestones_changed(before, added)
def test_new_top_level_completions_ordered():
before = parse_plan("- [x] A\n- [/] B\n- [ ] C")
after = parse_plan("- [x] A\n- [x] B\n- [x] C")
assert new_top_level_completions(before, after) == ["B", "C"]
# Sub-task completions never trigger
sub_after = parse_plan("- [x] A\n- [/] B\n - [x] B1\n- [ ] C")
assert new_top_level_completions(before, sub_after) == []
def test_unintended_milestone_edits_pairing():
before = parse_plan("- [x] A\n- [/] B\n- [ ] C")
reword_untouched = parse_plan("- [x] A modified\n- [/] B\n- [ ] C")
assert unintended_milestone_edits(before, reword_untouched) == [("A", "A modified")]
# Status changed on the same line: counts as an intentional touch
complete_and_annotate = parse_plan("- [x] A\n- [x] B (verified)\n- [ ] C")
assert unintended_milestone_edits(before, complete_and_annotate) == []
# Structural change: not this check's business
removed = parse_plan("- [x] A\n- [/] B")
assert unintended_milestone_edits(before, removed) == []
def test_subgoal_hash_stability():
assert subgoal_hash(" A ") == subgoal_hash("A")
item = parse_plan("- [/] Goal text").items[0]
assert item.key == subgoal_hash("Goal text")
CHECK_PLAN = """# Task Plan
- [x] Create the 7:30 AM alarm in the Clock app
- [ ] open clock app
- verify: the alarm list shows 7:30 AM with its toggle ON
- assert: an "Alarm set" toast should appear after creation
- [ ] Send the report email
- verify: the email appears in the Sent folder
- assert@end: no application crash dialog should ever appear
- verify@end: the device is back on the home screen
"""
def test_check_lines_parse_with_anchor_and_timing():
snap = parse_plan(CHECK_PLAN)
checks = snap.all_check_items
assert len(checks) == 5
alarm = next(i for i in snap.top_level if i.text.startswith("Create the"))
alarm_checks = snap.check_items_of(alarm)
assert [c.kind for c in alarm_checks] == ["verify", "assert"]
assert all(c.when == "on_complete" for c in alarm_checks)
assert alarm_checks[0].text == "the alarm list shows 7:30 AM with its toggle ON"
assert all(c.parent_key == alarm.key for c in alarm_checks)
task_level = snap.task_level_check_items
assert len(task_level) == 2
assert all(c.when == "at_end" for c in task_level)
assert all(c.parent_key is None for c in task_level)
assert task_level[0].kind == "assert"
assert task_level[1].kind == "verify"
def test_check_lines_do_not_disturb_machine_channel():
"""Check lines must be invisible to milestone hashing, drift detection,
and completion triggers."""
without = CHECK_PLAN
for line in list(without.splitlines()):
if "verify" in line or ("assert" in line and "- [" not in line):
without = without.replace(line + "\n", "")
with_snap = parse_plan(CHECK_PLAN)
without_snap = parse_plan(without)
assert with_snap.milestone_texts == without_snap.milestone_texts
assert [i.key for i in with_snap.items] == [i.key for i in without_snap.items]
assert not milestones_changed(without_snap, with_snap)
assert new_top_level_completions(without_snap, with_snap) == []
assert unintended_milestone_edits(without_snap, with_snap) == []
def test_check_items_changed_multiset():
a = parse_plan("- [ ] G\n - verify: X\n - verify: X")
b = parse_plan("- [ ] G\n - verify: X")
c = parse_plan("- [ ] G\n - verify: X\n - verify: X")
assert check_items_changed(a, b)
assert not check_items_changed(a, c)
# Status flips never register as check changes
d = parse_plan("- [x] G\n - verify: X\n - verify: X")
assert not check_items_changed(a, d)
def test_restore_missing_check_items_reanchors_under_parent():
before = "- [ ] G1\n - verify: V1\n- [ ] G2\n"
after = "- [x] G1\n- [ ] G2\n"
merged = restore_missing_check_items(before, after)
assert merged is not None
lines = merged.splitlines()
assert lines[0] == "- [x] G1"
assert lines[1] == " - verify: V1"
assert lines[2] == "- [ ] G2"
# Idempotent: nothing missing after the merge
assert restore_missing_check_items(before, merged) is None
def test_restore_missing_check_items_orphan_becomes_task_level_at_end():
before = "- [ ] G1\n - assert: A1\n- [ ] G2\n"
after = "- [ ] G2\n" # parent subgoal removed entirely
merged = restore_missing_check_items(before, after)
assert merged is not None
assert merged.splitlines()[-1] == "- assert@end: A1"
def test_restore_missing_check_items_keeps_additions():
before = "- [ ] G1\n - verify: V1\n"
after = "- [ ] G1\n - verify: V1\n - verify: V2 (new)\n"
assert restore_missing_check_items(before, after) is None
assert not missing_check_items(parse_plan(before), parse_plan(after))
def test_render_plan_grammar_spec_conditional():
base = render_plan_grammar_spec(include_checks=False)
extended = render_plan_grammar_spec(include_checks=True)
# Both-gates-off output carries zero trace of the checking feature
assert base == PLAN_GRAMMAR_SPEC
assert "verify" not in base and "assert" not in base
assert extended.startswith(base)
assert "- verify:" in extended and "- assert:" in extended and "@end" in extended
# The capability boundary is declared honestly
assert "POST-HOC" in extended.upper() or "post-hoc" in extended
def test_grammar_spec_renders_constants():
assert CONTINUOUS_LOOP_TAG in PLAN_GRAMMAR_SPEC
assert "[Loop]" in PLAN_GRAMMAR_SPEC
assert "[/]" in PLAN_GRAMMAR_SPEC and "[x]" in PLAN_GRAMMAR_SPEC
def test_prompts_carry_the_single_sourced_grammar():
"""Drift guard: the prompts that teach the plan format must splice in the
machine-enforced grammar spec and reference the formal continuous tag,
and must no longer key loop protection on Exit wording."""
agents_dir = Path(__file__).resolve().parents[3] / "artemis" / "agents"
planner_raw = (agents_dir / "planner" / "planner.json").read_text(encoding="utf-8")
operator_raw = (agents_dir / "operator" / "operator.json").read_text(encoding="utf-8")
for raw in (planner_raw, operator_raw):
assert "{{ plan_grammar }}" in raw
assert "[Loop:continuous]" in raw
# The old wording-keyed protection contract must not resurface
planner = json.loads(planner_raw)
guidelines = planner["blocks"]["validator_guidelines"]
assert "until manually stopped" not in guidelines
-77
View File
@@ -1,77 +0,0 @@
# 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.
import json
from artemis.utils.verification import (
append_verification_chat,
get_verification_chat_path,
get_verification_chat_rounds,
read_verification_chat,
)
def test_get_verification_chat_path(tmp_path):
subgoal_hash = "test_hash"
expected_path = tmp_path / "notes" / f"verification_chat_{subgoal_hash}.json"
path = get_verification_chat_path(tmp_path, subgoal_hash)
assert path == expected_path
def test_read_verification_chat_not_exists(tmp_path):
path = tmp_path / "non_existent.json"
turns = read_verification_chat(path)
assert turns == []
def test_read_verification_chat_exists(tmp_path):
path = tmp_path / "chat.json"
expected_turns = [{"role": "operator", "round": 1, "content": "hello"}]
path.write_text(json.dumps(expected_turns), encoding="utf-8")
turns = read_verification_chat(path)
assert turns == expected_turns
def test_get_verification_chat_rounds():
turns = [
{"role": "operator", "round": 1, "content": "op1"},
{"role": "checker", "round": 1, "content": "chk1"},
{"role": "operator", "round": 2, "content": "op2"},
]
max_op, max_chk = get_verification_chat_rounds(turns)
assert max_op == 2
assert max_chk == 1
def test_get_verification_chat_rounds_empty():
max_op, max_chk = get_verification_chat_rounds([])
assert max_op == 0
assert max_chk == 0
def test_append_verification_chat(tmp_path):
path = tmp_path / "chat.json"
# Append to empty/non-existent
success = append_verification_chat(path, "operator", "hello", 1)
assert success
assert path.exists()
turns = json.loads(path.read_text(encoding="utf-8"))
assert turns == [{"role": "operator", "round": 1, "content": "hello"}]
# Append again
success = append_verification_chat(path, "checker", "hi", 2)
assert success
turns = json.loads(path.read_text(encoding="utf-8"))
assert len(turns) == 2
assert turns[1] == {"role": "checker", "round": 2, "content": "hi"}