feat: harden video analysis and runtime resilience

This commit is contained in:
somew1nd committed 2026-08-25 20:58:25 -07:00
1 parent 5ada543a09
commit bb16991f2c
55 files changed
+5023 -437

No files matched your search

@@ -0,0 +1,57 @@
from unittest.mock import MagicMock
import pytest
from apps.admin_console.routers import sessions as sessions_router
@pytest.mark.asyncio
async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
repo = MagicMock()
repo.get_all_sessions.return_value = [
{
"session_id": "session-1",
"status": "completed",
"start_time": 1.0,
"device_info": '{"profile": "flash"}',
},
{
"session_id": "session-2",
"status": "completed",
"start_time": 2.0,
"device_info": '{"profile": "pro"}',
},
{
"session_id": "session-3",
"status": "completed",
"start_time": 3.0,
"device_info": None,
},
]
repo.get_video_recordings_map.return_value = {}
repo.get_latest_video_recordings_map.return_value = {
"session-1": {"status": "ready"},
"session-2": {"status": "unavailable"},
}
repo.get_llm_traces_for_profiles_map.return_value = {
"session-3": ['{"model": "gemini-3.7-pro"}']
}
repo.get_agent_trace_names_map.return_value = {}
monkeypatch.setattr(sessions_router, "session_repo", repo, raising=False)
monkeypatch.setattr(
sessions_router.media_service, "build_video_index", MagicMock(return_value={})
)
monkeypatch.setattr(
sessions_router.media_service, "resolve_video_url", MagicMock(return_value=None)
)
result = await sessions_router.list_sessions()
assert len(result) == 3
repo.get_latest_video_recordings_map.assert_called_once_with()
repo.get_llm_traces_for_profiles_map.assert_called_once_with(["session-3"])
repo.get_agent_trace_names_map.assert_called_once_with()
repo.get_video_recording_for_session.assert_not_called()
repo.get_llm_traces_for_profile.assert_not_called()
repo.get_agent_trace_names.assert_not_called()
+180 -23
View File
@@ -14,8 +14,8 @@
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
import asyncio
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock, Mock, call
from uuid import uuid4
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
@@ -65,27 +65,26 @@ async def test_summarizer_dispatch_and_caching(mock_context):
@pytest.mark.asyncio
async def test_summarizer_retrigger_stalled_steps(mock_context):
"""Verify that earlier stalled steps are re-triggered when subsequent steps arrive."""
async def test_summarizer_retries_independently(mock_context):
"""A failed summary retries without waiting for another action to arrive."""
summarizer = VisualStepSummarizer(mock_context)
attempt_count = 0
async def mock_ainvoke(messages):
nonlocal attempt_count
attempt_count += 1
# Fail the first attempt (Step 1 first call)
# Fail the first attempt, then return the requested step summary.
if attempt_count == 1:
raise asyncio.CancelledError("Simulated timeout")
# Check messages for step number
raise TimeoutError("Simulated timeout")
msg_str = str(messages)
if "Step 1" in msg_str:
return AIMessage(content="[Step 1 State: Recovered Step 1 summary]")
return AIMessage(content="[Step 2 State: Step 2 summary]")
raise AssertionError("Unexpected step")
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
# Dispatch step 1 (fails on first attempt)
# No Step 2 dispatch is needed to trigger the retry.
summarizer.dispatch(
step_number=1,
action_name="click",
@@ -95,22 +94,10 @@ async def test_summarizer_retrigger_stalled_steps(mock_context):
exec_outcome="Outcome 1",
)
await summarizer.flush()
assert not summarizer.has_summary(1)
# Dispatch step 2 (should detect stalled Step 1 and re-trigger)
summarizer.dispatch(
step_number=2,
action_name="swipe",
action_args={"action": "up"},
pre_img_bytes=b"pre2",
post_img_bytes=b"post2",
exec_outcome="Outcome 2",
)
await summarizer.flush()
assert summarizer.has_summary(1)
assert summarizer.has_summary(2)
assert "Recovered Step 1" in summarizer.get_summary(1)
assert attempt_count == 2
def test_context_compressor_with_ready_summaries(mock_context):
@@ -156,7 +143,10 @@ def test_context_compressor_with_ready_summaries(mock_context):
# Step 1 (ToolMessage 1): Image replaced with Step 1 summary, old XML pruned
assert len(messages[1].content) == 2
assert messages[1].content[0]["text"] == "Action 'click' completed."
assert messages[1].content[1]["text"] == "[Step 1 State: Tapped Search bar. Keyboard appeared.]"
assert messages[1].content[1]["text"] == (
"--- Historical Visual Transition ---\n"
"[Step 1 State: Tapped Search bar. Keyboard appeared.]"
)
# Step 2 (ToolMessage 2 - Latest live screen): Image and latest live XML MUST be preserved
assert len(messages[2].content) == 3
@@ -198,6 +188,173 @@ def test_context_compressor_graceful_fallback(mock_context):
assert messages[1].content[1]["type"] == "image_url"
def test_context_compressor_backfills_late_summary_idempotently(mock_context):
"""A pending image remains until its late summary replaces it."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._step_inputs["action-1"] = {"step_number": 1}
messages = [
HumanMessage(
content=[
{"type": "text", "text": "Task: Search"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}},
]
),
ToolMessage(
tool_call_id="action-1",
name="click",
content=[
{"type": "text", "text": "Tapped Search."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_2"}},
],
),
ToolMessage(
tool_call_id="action-2",
name="input_text",
content=[
{"type": "text", "text": "Typed query."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_3"}},
],
),
]
compress_flash_messages(messages, summarizer=summarizer)
assert any(block["type"] == "image_url" for block in messages[1].content)
assert len(messages[1].content) == 2
summarizer._summaries["action-1"] = "Search field focused; keyboard appeared."
compress_flash_messages(messages, summarizer=summarizer)
compress_flash_messages(messages, summarizer=summarizer)
generated_blocks = [
block
for block in messages[1].content
if block.get("text", "").startswith("--- Historical Visual Transition ---")
]
assert len(generated_blocks) == 1
assert generated_blocks[0]["text"].endswith("Search field focused; keyboard appeared.")
assert all(block["type"] != "image_url" for block in messages[1].content)
def test_context_compressor_uses_tool_call_id_over_message_ordinal(mock_context):
"""Generic tool messages cannot shift action summaries onto the wrong result."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._summaries["click-id"] = "Click-specific visual memory."
summarizer._summaries["type-id"] = "Typing-specific visual memory."
messages = [
ToolMessage(
tool_call_id="click-id",
name="click",
content=[
{"type": "text", "text": "Clicked."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}},
],
),
ToolMessage(
tool_call_id="explorer-id",
name="ask_explorer",
content=[{"type": "text", "text": "Explorer result."}],
),
ToolMessage(
tool_call_id="type-id",
name="input_text",
content=[
{"type": "text", "text": "Typed."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,B"}},
],
),
]
compress_flash_messages(messages, summarizer=summarizer)
assert "Click-specific visual memory." in str(messages[0].content)
assert "Typing-specific visual memory." not in str(messages[0].content)
assert "Visual Transition" not in str(messages[1].content)
# The latest live observation remains uncompressed until a newer screen exists.
assert messages[2].content[-1]["type"] == "image_url"
def test_context_compressor_preserves_text_before_embedded_ui_list(mock_context):
"""Pruning a combined XML block must retain its action result prefix."""
messages = [
ToolMessage(
tool_call_id="old",
name="click",
content=[
{
"type": "text",
"text": "Tapped Settings.\n--- UI Element List ---\n[huge tree]",
},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}},
],
),
ToolMessage(
tool_call_id="live",
name="click",
content=[
{"type": "text", "text": "Opened page."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,B"}},
],
),
]
compress_flash_messages(messages, prune_history_xml=True)
assert messages[0].content == [{"type": "text", "text": "Tapped Settings."}]
@pytest.mark.asyncio
async def test_summarizer_keys_actions_independently_of_turn_number(mock_context):
"""Multiple actions from one LLM turn keep independent inputs and summaries."""
mock_context.data_engine = Mock()
summarizer = VisualStepSummarizer(mock_context)
async def mock_ainvoke(messages):
prompt = str(messages)
if "first action" in prompt:
return AIMessage(content="First action memory.")
return AIMessage(content="Second action memory.")
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
first_step_id = uuid4()
second_step_id = uuid4()
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"label": "first action"},
pre_img_bytes=None,
post_img_bytes=None,
exec_outcome="one",
action_key="tc-1",
data_engine_step_id=first_step_id,
)
summarizer.dispatch(
step_number=2,
action_name="click",
action_args={"label": "second action"},
pre_img_bytes=None,
post_img_bytes=None,
exec_outcome="two",
action_key="tc-2",
data_engine_step_id=second_step_id,
)
await summarizer.flush()
assert summarizer.get_summary("tc-1") == "First action memory."
assert summarizer.get_summary("tc-2") == "Second action memory."
assert summarizer._step_inputs["tc-1"]["data_engine_step_id"] == first_step_id
assert summarizer._step_inputs["tc-2"]["data_engine_step_id"] == second_step_id
mock_context.data_engine.update_step_summary.assert_has_calls(
[
call(first_step_id, "First action memory."),
call(second_step_id, "Second action memory."),
],
any_order=True,
)
def test_flash_config_and_builder():
"""Verify Flash profile configuration model and SDK builder fluent API."""
cfg = Builders.AgentConfig.with_flash_config(
@@ -47,6 +47,18 @@ def test_operator_prompt_omits_environment_trust_and_explorer_directives():
assert "Visual Explorer Rule" not in prompt
def test_operator_prompt_keeps_large_list_traversal_single_pass_until_boundary():
for template in load_operator_prompts().values():
prompt = apply_operator_prompt_contract(template)
assert "do not advance to another milestone or mark it complete" in prompt
assert "A single scroll that reveals no new content is not sufficient evidence" in prompt
assert "one additional successful swipe in the same direction" in prompt
assert "never reverse direction merely to prove completion" in prompt
assert "even if older execution history has been pruned" in prompt
assert "Terminate exploration when a definitive boundary is reached" not in prompt
def test_compatibility_builder_uses_canonical_operator_template():
expected = apply_operator_prompt_contract(load_operator_prompts()["main_template"])
@@ -181,6 +181,9 @@ async def test_exec_analyze_audio_universal(mock_context, tmp_path):
assert "Heard notification sound" in result
assert len(agent.blackboard_entries) == 1
assert agent.blackboard_entries[0]["summary"] == "Heard notification sound."
sent_content = mock_bound.ainvoke.await_args.args[0][1].content
assert sent_content[1]["type"] == "audio"
assert sent_content[1]["base64"] == "YXVkaW9fYnl0ZXM="
@pytest.mark.asyncio
+283
View File
@@ -0,0 +1,283 @@
# 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 time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from artemis.agents.video_analyzer.blackboard import (
VideoBlackboard,
get_video_blackboard,
)
from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer, _invoke_with_retry
from artemis.context import ArtemisContext
def test_blackboard_persists_segments_observations_and_evidence(tmp_path):
db_path = tmp_path / "trace.db"
evidence_dir = tmp_path / "video_blackboard" / "evidence"
source_image = tmp_path / "temporary.jpg"
source_image.write_bytes(b"jpeg evidence")
board = VideoBlackboard("video:recording-1", db_path=db_path, evidence_dir=evidence_dir)
stored = board.add_observation(
{
"start": 1.0,
"end": 1.5,
"target": "verify login",
"summary": "Login screen became visible.",
"confidence_score": 0.95,
"screenshot": str(source_image),
}
)
# A duplicate commit is idempotent.
board.add_observation(stored)
claim = board.claim_segment(0.0, 5.0, "verify login")
assert claim.state == "claimed"
board.complete_segment(
0.0,
5.0,
"verify login",
claim.lease_owner,
"Login succeeded.",
"The authenticated home screen appeared.",
)
reopened = VideoBlackboard("video:recording-1", db_path=db_path, evidence_dir=evidence_dir)
observations = reopened.list_observations()
assert len(observations) == 1
persisted_evidence = observations[0]["screenshot"]
assert persisted_evidence != str(source_image)
assert (evidence_dir / f"{stored['observation_id']}.jpg").read_bytes() == b"jpeg evidence"
assert reopened.missing_intervals(0.0, 5.0, "verify login") == []
cached = reopened.claim_segment(0.0, 5.0, "verify login")
assert cached.state == "cached"
assert cached.summary == "Login succeeded."
assert any(entry.get("kind") == "segment_result" for entry in reopened.list_ledger_entries())
def test_blackboard_calculates_only_uncovered_intervals(tmp_path):
board = VideoBlackboard("video:coverage", db_path=tmp_path / "coverage.db")
for start, end in ((0.0, 10.0), (8.0, 20.0), (30.0, 40.0)):
claim = board.claim_segment(start, end, "find spinner")
board.complete_segment(
start, end, "find spinner", claim.lease_owner, f"Analyzed {start}-{end}"
)
assert board.missing_intervals(0.0, 50.0, "find spinner") == [
(20.0, 30.0),
(40.0, 50.0),
]
# Different questions can use the observations as context but do not share
# query-specific completion coverage.
assert board.missing_intervals(0.0, 10.0, "count spinner frames") == [(0.0, 10.0)]
def test_blackboard_lease_suppresses_duplicates_and_is_reclaimable():
board = VideoBlackboard("video:lease", lease_seconds=0.02)
first = board.claim_segment(0.0, 5.0, "verify tap")
assert first.state == "claimed"
assert board.claim_segment(0.0, 5.0, "verify tap").state == "in_progress"
time.sleep(0.03)
reclaimed = board.claim_segment(0.0, 5.0, "verify tap")
assert reclaimed.state == "claimed"
assert reclaimed.lease_owner != first.lease_owner
board.fail_segment(0.0, 5.0, "verify tap", reclaimed.lease_owner, "temporary provider outage")
assert board.claim_segment(0.0, 5.0, "verify tap").state == "claimed"
def test_get_video_blackboard_is_shared_by_context():
ctx = MagicMock(spec=ArtemisContext)
ctx._video_blackboard = None
ctx.data_engine = None
ctx.execution_setup = None
ctx.device = MagicMock()
ctx.device.device_id = "device-1"
first = get_video_blackboard(ctx)
first.add_observation(
{
"start": 0.0,
"end": 1.0,
"target": "shared",
"summary": "Persisted in context.",
}
)
second = get_video_blackboard(ctx)
assert first is second
assert second.list_observations()[0]["summary"] == "Persisted in context."
@pytest.fixture
def analyzer_context():
ctx = MagicMock(spec=ArtemisContext)
ctx._video_blackboard = None
ctx._mobile_controller = None
ctx.data_engine = None
ctx.execution_setup = None
ctx.agent_config = None
ctx.device = MagicMock()
ctx.device.device_id = "device-analyzer"
ctx.llm_config = MagicMock()
ctx.llm_config.utils.video_analyzer.model = "universal-test-model"
ctx.llm_config.utils.video_analyzer.temperature = 0.2
return ctx
@pytest.mark.asyncio
async def test_spawn_sub_agent_reuses_complete_persistent_coverage(analyzer_context):
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(analyzer_context)
claim = analyzer.blackboard.claim_segment(10.0, 20.0, "verify login")
analyzer.blackboard.complete_segment(
10.0,
20.0,
"verify login",
claim.lease_owner,
"Login succeeded.",
"Home screen appeared.",
)
with patch.object(analyzer, "_exec_single_chunk", new_callable=AsyncMock) as child:
result = await analyzer.exec_spawn_sub_agent(10.0, 20.0, "verify login")
child.assert_not_awaited()
assert result.startswith("CACHED VIDEO ANALYSIS:")
assert "Login succeeded" in result
@pytest.mark.asyncio
async def test_spawn_sub_agent_returns_partial_and_keeps_prior_success(analyzer_context):
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(analyzer_context)
claim = analyzer.blackboard.claim_segment(0.0, 60.0, "summarize flow")
analyzer.blackboard.complete_segment(
0.0,
60.0,
"summarize flow",
claim.lease_owner,
"First minute completed.",
)
with patch.object(
analyzer,
"_exec_single_chunk",
new_callable=AsyncMock,
side_effect=RuntimeError("provider unavailable"),
) as child:
result = await analyzer.exec_spawn_sub_agent(0.0, 120.0, "summarize flow")
child.assert_awaited_once_with(60.0, 120.0, "summarize flow")
assert result.startswith("PARTIAL VIDEO ANALYSIS")
assert "First minute completed" in result
assert analyzer.blackboard.missing_intervals(0.0, 120.0, "summarize flow") == [(60.0, 120.0)]
@pytest.mark.asyncio
async def test_video_coordinator_retries_transient_failures():
operation = AsyncMock(
side_effect=[TimeoutError("timeout one"), ConnectionError("reset"), "completed"]
)
with patch(
"artemis.agents.video_analyzer.video_analyzer.asyncio.sleep", new_callable=AsyncMock
) as sleep:
result = await _invoke_with_retry(operation, "test coordinator")
assert result == "completed"
assert operation.await_count == 3
assert sleep.await_count == 2
@pytest.mark.asyncio
async def test_audio_analysis_reuses_exact_completed_coverage(analyzer_context):
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(analyzer_context)
claim = analyzer.blackboard.claim_segment(
2.0, 6.0, "hear notification", modality="audio"
)
analyzer.blackboard.complete_segment(
2.0,
6.0,
"hear notification",
claim.lease_owner,
"A notification sounded.",
"One short bell was audible.",
modality="audio",
)
with patch(
"artemis.agents.video_analyzer.video_analyzer.get_controller"
) as controller:
result = await analyzer.exec_analyze_audio_only(
2.0, 6.0, "hear notification"
)
controller.assert_not_called()
assert result.startswith("CACHED AUDIO ANALYSIS:")
assert "notification sounded" in result
def test_blackboard_metrics_include_structured_failures(tmp_path):
board = VideoBlackboard("video:metrics", db_path=tmp_path / "metrics.db")
claim = board.claim_segment(
0.0,
5.0,
"inspect",
model_name="vlm-primary",
source_generation=2,
)
board.fail_segment(
0.0,
5.0,
"inspect",
claim.lease_owner,
"timeout",
error_category="timeout",
)
assert board.metrics()["segments"] == {"retryable_failed": 1}
assert board.metrics()["failure_categories"] == {"timeout": 1}
def test_session_deletion_removes_video_blackboard_rows(tmp_path):
from uuid import uuid4
from artemis.data_engine.engine import DataEngine
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = MagicMock(traces_path=str(tmp_path / "traces"))
ctx.device = None
engine = DataEngine(ctx)
session_id = engine.start_session(goal="blackboard cleanup")
video_id = uuid4()
recording = tmp_path / "recording.mp4"
recording.write_bytes(b"video")
engine.record_video_start(video_id, "device", recording)
board = VideoBlackboard(f"video:{video_id}", db_path=engine.storage.db_path)
board.add_observation(
{"start": 0.0, "end": 1.0, "target": "cleanup", "summary": "evidence"}
)
assert board.list_observations()
engine.storage.delete_session(session_id)
reopened = VideoBlackboard(f"video:{video_id}", db_path=engine.storage.db_path)
assert reopened.list_observations() == []
+193
View File
@@ -0,0 +1,193 @@
# 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.
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from langchain_core.messages import AIMessage, HumanMessage
import pytest
from artemis.agents.video_analyzer.reliability import (
VideoCircuitBreaker,
VideoFailureCategory,
classify_video_failure,
)
from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer
from artemis.context import ArtemisContext
def _context() -> MagicMock:
ctx = MagicMock(spec=ArtemisContext)
ctx._video_blackboard = None
ctx._video_circuit_breaker = None
ctx._mobile_controller = None
ctx.execution_setup = None
ctx.data_engine = None
ctx.agent_config = SimpleNamespace(
video_analyzer=SimpleNamespace(
enable_ledger=True,
chunk_size_seconds=60.0,
min_chunk_seconds=4.0,
max_split_depth=4,
circuit_breaker_threshold=2,
circuit_breaker_cooldown_seconds=30.0,
action_window_seconds=2.0,
dense_action_fps=4.0,
max_dense_action_frames=24,
)
)
ctx.device = SimpleNamespace(device_id="reliability-device")
ctx.llm_config = MagicMock()
ctx.llm_config.utils.video_analyzer.model = "primary-video-model"
ctx.llm_config.utils.video_analyzer.temperature = 0.2
return ctx
def test_failure_classifier_chooses_safe_recovery():
outage = classify_video_failure(RuntimeError("503 service unavailable"))
assert outage.category == VideoFailureCategory.PROVIDER_UNAVAILABLE
assert outage.retryable and outage.should_fallback
assert not outage.should_split
timeout = classify_video_failure(TimeoutError("clip timed out"))
assert timeout.category == VideoFailureCategory.TIMEOUT
assert timeout.should_split and timeout.should_fallback
invalid = SimpleNamespace(code=400)
invalid_error = RuntimeError("bad request")
invalid_error.code = invalid.code
classified = classify_video_failure(invalid_error)
assert classified.category == VideoFailureCategory.BAD_REQUEST
assert not classified.retryable
def test_circuit_breaker_opens_and_half_opens():
breaker = VideoCircuitBreaker(threshold=2, cooldown_seconds=10.0)
failure = classify_video_failure(TimeoutError("timeout"))
breaker.record_failure("model", failure, now=10.0)
assert breaker.allow("model", now=10.1)
breaker.record_failure("model", failure, now=11.0)
assert not breaker.allow("model", now=15.0)
assert breaker.allow("model", now=21.1)
assert not breaker.allow("model", now=21.2)
breaker.record_success("model")
assert breaker.snapshot() == {}
@pytest.mark.asyncio
async def test_timeout_chunk_is_bisected_and_successful_leaves_survive():
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(_context())
async def analyze(start: float, end: float, query: str) -> str:
if end - start > 4.0:
raise TimeoutError("large clip timed out")
return f"[from {start:.1f}s to {end:.1f}s] Summary: recovered {query}"
with patch.object(analyzer, "_exec_single_chunk", side_effect=analyze) as child:
result = await analyzer.exec_spawn_sub_agent(0.0, 16.0, "find transition")
assert child.await_count == 7
assert result.count("recovered find transition") == 4
assert "PARTIAL" not in result
@pytest.mark.asyncio
async def test_universal_primary_failure_uses_configured_fallback():
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(_context())
primary = MagicMock()
primary_bound = MagicMock()
primary_bound.ainvoke = AsyncMock(side_effect=TimeoutError("primary timeout"))
primary.bind_tools.return_value = primary_bound
fallback = MagicMock()
fallback_bound = MagicMock()
fallback_bound.ainvoke = AsyncMock(return_value=AIMessage(content="fallback answer"))
fallback.bind_tools.return_value = fallback_bound
with patch(
"artemis.agents.video_analyzer.video_analyzer.get_llm",
side_effect=[primary, fallback],
) as get_model:
response = await analyzer._invoke_universal_model(
[HumanMessage(content="inspect")],
[],
timeout=1.0,
label="test",
)
assert response.content == "fallback answer"
assert get_model.call_args_list[0].kwargs["is_utils"] is True
assert get_model.call_args_list[1].kwargs["use_fallback"] is True
@pytest.mark.asyncio
async def test_native_chunk_exhaustion_commits_universal_fallback(tmp_path):
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(_context())
analyzer.use_native_gemini = True
raw_video = tmp_path / "segment.mp4"
raw_video.write_bytes(b"video")
extracted = SimpleNamespace(
success=True,
video_path=raw_video,
actual_start_relative_time=0.0,
duration_seconds=5.0,
warning=None,
)
controller = SimpleNamespace(
extract_segment_metadata=AsyncMock(return_value=extracted)
)
fallback_text = "[from 0.0s to 5.0s] Summary: fallback recovered Analysis: grounded"
with (
patch(
"artemis.agents.video_analyzer.video_analyzer.get_controller",
return_value=controller,
),
patch(
"artemis.agents.video_analyzer.video_analyzer.compress_video_for_api",
new=AsyncMock(return_value=raw_video),
),
patch.object(
analyzer,
"upload_and_poll_file",
new=AsyncMock(side_effect=RuntimeError("503 provider unavailable")),
) as upload,
patch.object(
analyzer,
"_exec_single_chunk_universal",
new=AsyncMock(return_value=fallback_text),
) as fallback,
patch(
"artemis.agents.video_analyzer.video_analyzer.asyncio.sleep",
new=AsyncMock(),
),
):
result = await analyzer._exec_single_chunk(0.0, 5.0, "verify transition")
assert result == fallback_text
assert upload.await_count == 2
fallback.assert_awaited_once()
assert analyzer.blackboard.missing_intervals(0.0, 5.0, "verify transition") == []
def test_action_timestamps_drive_dense_sampling():
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(_context())
step = SimpleNamespace(timestamp=104.0, action_taken={"name": "tap"})
storage = SimpleNamespace(get_steps=lambda _session: [step])
analyzer.ctx.data_engine = SimpleNamespace(
current_session_id="session-1",
session_start_time=100.0,
storage=storage,
)
offsets = analyzer._dense_action_offsets(0.0, 10.0, actual_start=0.0)
assert offsets[0] == 2.0
assert 4.0 in offsets
assert offsets[-1] == 6.0
+124 -1
View File
@@ -14,7 +14,8 @@
"""Unit tests for Artemis System Diagnostics & Readiness Engine."""
from unittest.mock import AsyncMock
import asyncio
from unittest.mock import AsyncMock, Mock
import pytest
from artemis.core.diagnostics.engine import ReadinessEngine
@@ -139,6 +140,128 @@ def test_adb_probe_parses_device_lock_state(policy_output, trust_output, expecte
assert AdbDeviceProbe._parse_device_lock_state(policy_output, trust_output) is expected
def test_modern_unlock_state_is_not_overridden_by_legacy_fields():
policy = (
"KeyguardServiceDelegate\n"
" showing=false\n"
"mShowingLockscreen=true mKeyguardOccluded=false"
)
trust = 'User "Owner" (current): deviceLocked=0'
assert AdbDeviceProbe._parse_device_lock_state(policy, trust) is False
@pytest.mark.asyncio
async def test_positive_lock_state_requires_confirmation(monkeypatch):
probe = AdbDeviceProbe()
raw_probe = AsyncMock(side_effect=[True, None])
monkeypatch.setattr(probe, "_get_device_lock_state", raw_probe)
result = await probe._get_confirmed_device_lock_state("adb", "device-1")
assert result is None
assert raw_probe.await_count == 2
@pytest.mark.asyncio
async def test_dashboard_reuses_recent_confirmed_state_on_one_timeout(monkeypatch):
probe = AdbDeviceProbe()
confirmed_probe = AsyncMock(side_effect=[False, None])
monkeypatch.setattr(
probe, "_get_confirmed_device_lock_state", confirmed_probe
)
assert await probe._get_dashboard_lock_state("adb", "device-1") is False
assert await probe._get_dashboard_lock_state("adb", "device-1") is False
assert probe._lock_state_sources["device-1"] == "recent_confirmed"
@pytest.mark.asyncio
async def test_readiness_engine_coalesces_concurrent_full_scans():
engine = ReadinessEngine()
result = ProbeResult(
id="test_probe",
category=ProbeCategory.RUNTIME,
title="Test",
status=ProbeStatus.PASS,
is_blocker=True,
summary="Ready",
description="Ready",
)
async def slow_probe():
await asyncio.sleep(0.01)
return result
probe = Mock()
probe.probe = AsyncMock(side_effect=slow_probe)
engine._probes = {"test_probe": probe}
reports = await asyncio.gather(*(engine.run_all() for _ in range(8)))
assert probe.probe.await_count == 1
assert all(report.overall_ready for report in reports)
@pytest.mark.asyncio
async def test_readiness_engine_reuses_cache_until_forced():
engine = ReadinessEngine()
result = ProbeResult(
id="test_probe",
category=ProbeCategory.RUNTIME,
title="Test",
status=ProbeStatus.PASS,
is_blocker=True,
summary="Ready",
description="Ready",
)
probe = Mock()
probe.probe = AsyncMock(return_value=result)
engine._probes = {"test_probe": probe}
first = await engine.run_all()
cached = await engine.run_all()
refreshed = await engine.run_all(force_refresh=True)
assert probe.probe.await_count == 2
assert cached.timestamp == first.timestamp
assert refreshed.timestamp >= first.timestamp
@pytest.mark.asyncio
async def test_invalidation_prevents_in_flight_report_from_becoming_shared_cache():
engine = ReadinessEngine()
result = ProbeResult(
id="test_probe",
category=ProbeCategory.RUNTIME,
title="Test",
status=ProbeStatus.PASS,
is_blocker=True,
summary="Ready",
description="Ready",
)
started = asyncio.Event()
release = asyncio.Event()
async def controlled_probe():
started.set()
await release.wait()
return result
probe = Mock()
probe.probe = AsyncMock(side_effect=controlled_probe)
engine._probes = {"test_probe": probe}
old_scan = asyncio.create_task(engine.run_all())
await started.wait()
engine.invalidate_cache()
release.set()
await old_scan
await engine.run_all()
assert probe.probe.await_count == 2
@pytest.mark.asyncio
async def test_submission_probe_skips_full_device_enrichment(monkeypatch):
probe = AdbDeviceProbe(target_serial="device-2")
+23
View File
@@ -101,6 +101,29 @@ def test_ipc_does_not_reconnect_after_engine_shutdown(tmp_path):
connected_socket.close.assert_called_once()
def test_ipc_connection_failure_is_backed_off(tmp_path):
"""A stale desktop port must not block every emitted trace event."""
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
with (
patch("artemis.data_engine.engine.read_ipc_port", return_value=49152),
patch("artemis.data_engine.engine.get_ipc_port_file", return_value=tmp_path / "none"),
patch(
"artemis.data_engine.engine.socket.create_connection",
side_effect=TimeoutError("stale port"),
) as create_connection,
):
engine = DataEngine(mock_ctx)
for index in range(20):
engine._publish("llm_stream", {"chunk": str(index)})
assert create_connection.call_count == 1
def test_get_or_create_image_updates_missing_data(tmp_path):
# Setup mock context
mock_ctx = MagicMock(spec=ArtemisContext)
@@ -0,0 +1,37 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from mcp_server.background.task_runner import _initialize_agent
@pytest.mark.asyncio
async def test_background_agent_initialization_has_hard_timeout():
async def slow_init(**_):
await asyncio.sleep(60)
agent = MagicMock()
agent.init = AsyncMock(side_effect=slow_init)
with pytest.raises(TimeoutError, match="initialization exceeded 0.0s"):
await _initialize_agent(
agent,
retry_count=1,
retry_wait_seconds=1,
timeout_seconds=0.01,
)
@pytest.mark.asyncio
async def test_background_agent_initialization_forwards_health_settings():
agent = AsyncMock()
await _initialize_agent(
agent,
retry_count=3,
retry_wait_seconds=4,
timeout_seconds=1.0,
)
agent.init.assert_awaited_once_with(retry_count=3, retry_wait_seconds=4)
+34
View File
@@ -0,0 +1,34 @@
# 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.
from unittest.mock import MagicMock, patch
from artemis.context import ArtemisContext
from artemis.controllers.controller_factory import get_controller
from artemis.controllers.unified_controller import UnifiedMobileController
def test_get_controller_reuses_context_scoped_controller():
ctx = MagicMock(spec=ArtemisContext)
ctx._mobile_controller = None
controller = object.__new__(UnifiedMobileController)
with patch(
"artemis.controllers.controller_factory.create_device_controller",
return_value=controller,
) as create:
assert get_controller(ctx) is controller
assert get_controller(ctx) is controller
create.assert_called_once_with(ctx)
+33 -1
View File
@@ -16,7 +16,7 @@
import base64
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -166,3 +166,35 @@ async def test_android_driver_parses_xml_hierarchy_fallback():
assert screen_data.ui_hierarchy_xml is not None
assert any(element.get("text") == "Settings" for element in screen_data.ui_elements)
@pytest.mark.asyncio
async def test_find_element_prefers_fresh_bounds_over_stale_center():
"""Layout transitions must not leave dynamic element taps at old coordinates."""
driver = MockDeviceDriver(device_id="test-mock", width=1080, height=2400)
driver.get_screen_data = AsyncMock(
return_value=ScreenData(
screenshot_bytes=base64.b64decode(_ONE_PIXEL_PNG),
screenshot_base64=_ONE_PIXEL_PNG,
ui_elements=[
{
"resource_id": "app:id/digit_4",
"bounds": "[10,1700][270,1940]",
"parsed_bounds": {
"left": 10,
"top": 1700,
"right": 270,
"bottom": 1940,
},
"center": [140, 1600],
}
],
width=1080,
height=2400,
)
)
_, center, error = await driver.find_element(resource_id="app:id/digit_4")
assert error is None
assert center == [140, 1820]
@@ -16,6 +16,7 @@
import subprocess
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
@@ -28,6 +29,8 @@ from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.utils.video import (
RecordingSession,
build_scrcpy_record_command,
extract_audio_from_video,
extract_frames_at_timestamps,
get_ffmpeg_path,
get_active_session,
normalize_recording_to_mp4,
@@ -37,6 +40,33 @@ from artemis.utils.video import (
)
@pytest.mark.asyncio
async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_path):
silent_video = tmp_path / "silent.mp4"
silent_video.write_bytes(b"video")
probe = MagicMock(returncode=0)
probe.communicate = AsyncMock(return_value=(b"", b""))
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=probe)) as spawn:
with pytest.raises(ValueError, match="no audio stream"):
await extract_audio_from_video(silent_video)
assert spawn.await_count == 1
def test_targeted_frame_extraction_uses_requested_timestamps():
fixture = Path(__file__).parents[1] / "tools" / "inputs" / "recording.mp4"
frames = extract_frames_at_timestamps(
fixture,
[0.0, 0.2, 0.4, 0.2, -1.0],
max_frames=3,
max_dimension=320,
)
assert [timestamp for timestamp, _ in frames] == [0.0, 0.2, 0.4]
assert all(data.startswith(b"\xff\xd8") for _, data in frames)
def test_scrcpy_recording_locks_each_segment_orientation(tmp_path):
output_path = tmp_path / "recording.mkv"
@@ -292,6 +322,38 @@ async def test_unified_controller_extract_segment_metadata(mock_ctx, tmp_path):
remove_active_session("emulator-5554")
@pytest.mark.asyncio
async def test_segment_cache_is_scoped_to_recording_generation(mock_ctx, tmp_path):
controller = UnifiedMobileController(mock_ctx)
remove_active_session("emulator-5554")
recording = tmp_path / "recording.mkv"
recording.write_bytes(b"recording")
session = RecordingSession(
video_id=uuid4(),
device_id="emulator-5554",
start_time=time.time() - 20.0,
data_engine_start_time=time.time() - 20.0,
local_video_path=recording,
process=MagicMock(returncode=None),
)
set_active_session("emulator-5554", session)
render = AsyncMock(
side_effect=lambda source, start, end, output: output.write_bytes(b"clip") or True
)
with patch("artemis.controllers.unified_controller.render_timeline_clip", render):
first = await controller.extract_segment_metadata(2.0, 8.0)
cached = await controller.extract_segment_metadata(2.0, 8.0)
session.generation = 1
regenerated = await controller.extract_segment_metadata(2.0, 8.0)
assert first is cached
assert regenerated is not cached
assert regenerated.generation == 1
assert render.await_count == 2
remove_active_session("emulator-5554")
def test_data_engine_video_lifecycle(tmp_path):
from artemis.data_engine.engine import DataEngine
from artemis.context import ArtemisContext