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

Expose verification and Explorer settings with streamed check results.

Support graceful cancellation, recording recovery and bundled UI resources.

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

200 lines
5.9 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""HistoricalStateHintPromptComponent (M4): perceptual-hash hint + silence rules.
Division of labor under test: the hint fires only for matches *older* than the
recent window (returned-to-an-early-state regime); any match within the last 3
steps silences it entirely — that regime belongs to the pixel-level
ScreenshotSimilarityPromptComponent.
"""
import base64
import io
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image, ImageDraw
from artemis.agents.operator.prompts import HistoricalStateHintPromptComponent, PromptBuilder
from artemis.utils.image_hash import dhash_hex
def _jpeg(draw_fn) -> bytes:
img = Image.new("RGB", (270, 600), "white")
draw_fn(ImageDraw.Draw(img))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
def _screen_a(d):
d.rectangle([20, 40, 250, 90], fill="#3355ff")
d.rectangle([60, 450, 210, 510], fill="#22aa66")
def _screen_b(d):
for y in range(60, 560, 80):
d.rectangle([10, y, 260, y + 50], fill="#dddddd")
SCREEN_A = _jpeg(_screen_a)
SCREEN_B = _jpeg(_screen_b)
HASH_A = dhash_hex(SCREEN_A)
HASH_B = dhash_hex(SCREEN_B)
B64_A = base64.b64encode(SCREEN_A).decode("utf-8")
def _step(number: int, post_hash: str | None) -> dict:
meta = {"post_image_dhash": post_hash} if post_hash else {}
return {"step_number": number, "extra_metadata": meta}
def _cfg(similarity_hint=True, max_distance=8):
return SimpleNamespace(
memory=SimpleNamespace(
transcript=SimpleNamespace(
similarity_hint=similarity_hint,
similarity_max_distance=max_distance,
)
)
)
async def _run(steps, b64=B64_A, cfg=None):
builder = PromptBuilder()
component = HistoricalStateHintPromptComponent()
with patch("artemis.config.load_agent_config", return_value=cfg or _cfg()):
await component(builder, MagicMock(), MagicMock(), latest_screenshot_b64=b64, steps=steps)
return [p for p in builder.human_parts if isinstance(p, str)]
@pytest.mark.asyncio
async def test_hint_fires_on_old_matching_step():
steps = [
_step(1, HASH_B),
_step(2, HASH_A), # old match
_step(3, HASH_B),
_step(4, HASH_B),
_step(5, HASH_B),
_step(6, HASH_B),
]
parts = await _run(steps)
assert len(parts) == 1
assert "Historical state hint" in parts[0]
assert "Step 2" in parts[0]
assert "recall_history" in parts[0]
@pytest.mark.asyncio
async def test_silent_when_recent_step_also_matches():
steps = [
_step(1, HASH_A), # old match exists...
_step(2, HASH_B),
_step(3, HASH_B),
_step(4, HASH_B),
_step(5, HASH_A), # ...but a recent (last-3) match silences the hint
_step(6, HASH_B),
]
assert await _run(steps) == []
@pytest.mark.asyncio
async def test_silent_without_any_match():
steps = [_step(n, HASH_B) for n in range(1, 7)]
assert await _run(steps) == []
@pytest.mark.asyncio
async def test_silent_when_config_disabled():
steps = [
_step(1, HASH_A),
_step(2, HASH_B),
_step(3, HASH_B),
_step(4, HASH_B),
_step(5, HASH_B),
_step(6, HASH_B),
]
assert await _run(steps, cfg=_cfg(similarity_hint=False)) == []
@pytest.mark.asyncio
async def test_silent_with_too_few_steps():
# Everything within the recent window belongs to the pixel-level note.
assert await _run([_step(1, HASH_A), _step(2, HASH_A)]) == []
@pytest.mark.asyncio
async def test_tie_prefers_most_recent_old_step():
steps = [
_step(1, HASH_A),
_step(2, HASH_A), # same distance, more recent
_step(3, HASH_B),
_step(4, HASH_B),
_step(5, HASH_B),
_step(6, HASH_B),
]
parts = await _run(steps)
assert len(parts) == 1 and "Step 2" in parts[0]
@pytest.mark.asyncio
async def test_steps_without_hashes_are_skipped():
steps = [
_step(1, None),
_step(2, None),
_step(3, None),
_step(4, None),
_step(5, None),
_step(6, None),
]
assert await _run(steps) == []
def test_record_step_stamps_perceptual_hashes(tmp_path):
from artemis.context import ArtemisContext
from artemis.data_engine.engine import DataEngine
mock_ctx = MagicMock(spec=ArtemisContext)
setup = MagicMock()
setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = setup
mock_ctx.device = None
engine = DataEngine(mock_ctx)
engine.start_session("dhash stamping test")
engine.record_step(
pre_screenshot_bytes=SCREEN_A,
post_screenshot_bytes=SCREEN_B,
summary="two distinct screens",
)
# Same pre/post bytes: post_image_name is nulled but the post-action
# screen IS the pre screen, so the post hash mirrors the pre hash.
engine.record_step(
pre_screenshot_bytes=SCREEN_A,
post_screenshot_bytes=SCREEN_A,
summary="screen unchanged",
)
for t in list(engine._pending_threads):
t.join()
steps = engine.storage.get_steps(engine.current_session_id)
meta1 = steps[0].extra_metadata
assert meta1["pre_image_dhash"] == HASH_A
assert meta1["post_image_dhash"] == HASH_B
meta2 = steps[1].extra_metadata
assert meta2["pre_image_dhash"] == HASH_A
assert meta2["post_image_dhash"] == HASH_A