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.
200 lines
5.9 KiB
Python
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
|