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:
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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
Reference in new issue
Block a user