test: add comprehensive unit, integration, and e2e test suites
Introduce complete test coverage for the ARTEMIS platform across multiple layers: Unit tests: Add tests for agent nodes (Planner, Operator, Validator, Diagnoser, FlashRunner, Explorer), tools, utils (OCR/XML fusion, UI hierarchy), and data engine. Integration tests: Add live tool verification, reactive runner, IME, and standalone workflow tests. E2E tests: Add end-to-end scenario tests for media playback and failure recovery flows. Fixtures & mocks: Add mock inputs (audio, video, screenshot) and test utilities.
This commit is contained in:
1 parent
c1803fcb86
commit
5d2283deaa
115 files changed
+22813
No files matched your search
@@ -0,0 +1,167 @@
|
||||
# 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 pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.data_engine.engine import DataEngine
|
||||
from artemis.data_engine.trace import CURRENT_TRACE_ID, DataEngineCallbackHandler
|
||||
from artemis.utils.task_tree import build_plan_and_history
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace():
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
yield Path(tmpdir)
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_thoughts_non_duplication(temp_workspace):
|
||||
# 1. Setup mock context and DataEngine
|
||||
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||
mock_execution_setup = MagicMock()
|
||||
mock_execution_setup.traces_path = str(temp_workspace / "traces")
|
||||
mock_ctx.execution_setup = mock_execution_setup
|
||||
mock_ctx.device = None
|
||||
mock_ctx.llm_config = MagicMock()
|
||||
|
||||
engine = DataEngine(mock_ctx)
|
||||
mock_ctx.data_engine = engine
|
||||
|
||||
# Start session
|
||||
engine.start_session("Test initial goal")
|
||||
|
||||
# 2. Allocate step ID
|
||||
step_id = engine.allocate_step_id()
|
||||
|
||||
# 3. Simulate Operator Agent run starts
|
||||
operator_trace_id = uuid4()
|
||||
engine.record_trace(
|
||||
type="agent",
|
||||
name="operator",
|
||||
payload={"args": {}},
|
||||
trace_id=operator_trace_id,
|
||||
step_id=step_id,
|
||||
status="running",
|
||||
)
|
||||
|
||||
# Set context variables for parenting
|
||||
token = CURRENT_TRACE_ID.set(operator_trace_id)
|
||||
try:
|
||||
# 4. Simulate LLM Call callback sequence
|
||||
handler = DataEngineCallbackHandler(mock_ctx)
|
||||
llm_run_id = uuid4()
|
||||
|
||||
# Start model
|
||||
handler.on_chat_model_start(
|
||||
serialized={"name": "ChatGoogleGenerativeAI"},
|
||||
messages=[[HumanMessage(content="What should I do?")]],
|
||||
run_id=llm_run_id,
|
||||
parent_run_id=None,
|
||||
)
|
||||
|
||||
# End model (generating thought + action)
|
||||
class MockGeneration:
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
class MockLLMResult:
|
||||
def __init__(self, generations):
|
||||
self.generations = generations
|
||||
|
||||
response_message = AIMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "I need to tap the search bar to find books.",
|
||||
},
|
||||
{"type": "text", "text": "Tapping search bar."},
|
||||
]
|
||||
)
|
||||
mock_response = MockLLMResult(generations=[[MockGeneration(response_message)]])
|
||||
|
||||
handler.on_llm_end(mock_response, run_id=llm_run_id)
|
||||
finally:
|
||||
CURRENT_TRACE_ID.reset(token)
|
||||
|
||||
# 5. Finish Operator Agent trace
|
||||
engine.record_trace(
|
||||
type="agent",
|
||||
name="operator",
|
||||
payload={"result": "Success"},
|
||||
trace_id=operator_trace_id,
|
||||
step_id=step_id,
|
||||
status="success",
|
||||
)
|
||||
|
||||
# 6. Record step
|
||||
engine.record_step(
|
||||
pre_screenshot_bytes=b"pre_screenshot",
|
||||
ui_tree=[],
|
||||
ocr_result=[],
|
||||
action_taken=[{"action": "tap", "coordinates": [100, 200]}],
|
||||
operator_raw_thinking="I need to tap the search bar to find books.",
|
||||
operator_native_thinking="I need to tap the search bar to find books.",
|
||||
last_execution_result={"status": "success"},
|
||||
summary="Tapped search bar",
|
||||
)
|
||||
|
||||
# Flush any pending tasks
|
||||
await engine.shutdown()
|
||||
|
||||
# 7. Retrieve agent-friendly steps
|
||||
steps = engine.get_agent_friendly_steps()
|
||||
|
||||
# Assert step was retrieved
|
||||
assert len(steps) == 1
|
||||
step_data = steps[0]
|
||||
|
||||
# Assert thoughts are present in interleaved events (since we associated step_id now!)
|
||||
events = step_data.get("interleaved_events", [])
|
||||
thought_events = [e for e in events if e.get("type") in ("thought", "native_thought")]
|
||||
assert len(thought_events) > 0
|
||||
assert any(
|
||||
e["content"] == "I need to tap the search bar to find books." for e in thought_events
|
||||
)
|
||||
|
||||
# 8. Build history and check for duplications
|
||||
plan = "- [ ] Search for books"
|
||||
history = build_plan_and_history(
|
||||
plan, steps, "default", last_n_detailed=1, for_failure_analyzer=True
|
||||
)
|
||||
|
||||
print("\n--- Generated History Output ---")
|
||||
print(history)
|
||||
print("--------------------------------")
|
||||
|
||||
# Assert thought is printed
|
||||
assert "I need to tap the search bar to find books." in history
|
||||
|
||||
# Assert thought is NOT printed multiple times
|
||||
# Count occurrences of the thought string
|
||||
count = history.count("I need to tap the search bar to find books.")
|
||||
# The thought might appear in native thinking block and raw thinking block if both were stored.
|
||||
# In our test we set BOTH operator_raw_thinking and operator_native_thinking to the same string,
|
||||
# AND the LLM trace generated both "thinking" block and "text" block.
|
||||
# Let's count how many times it was actually rendered in the Operator Decision Loop.
|
||||
# It should appear at most 2 times (once as Native Thought, once as Thought/Raw Thought).
|
||||
# If there was duplicate appending due to fallback failure, it would appear 4+ times.
|
||||
assert count <= 2
|
||||
@@ -0,0 +1,185 @@
|
||||
# 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
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.data_engine.engine import DataEngine
|
||||
|
||||
|
||||
def test_get_or_create_image_updates_missing_data(tmp_path):
|
||||
# Setup mock context
|
||||
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
|
||||
|
||||
# Initialize DataEngine
|
||||
engine = DataEngine(mock_ctx)
|
||||
|
||||
image_bytes = b"fake_image_bytes"
|
||||
|
||||
# 1. Save image without OCR/UI tree
|
||||
image_name1 = engine.get_or_create_image(image_bytes)
|
||||
|
||||
# Verify it was saved with None for OCR/UI tree
|
||||
record1 = engine.storage.get_image(image_name1)
|
||||
assert record1 is not None
|
||||
assert record1.ocr_result is None
|
||||
assert record1.ui_tree is None
|
||||
|
||||
# 2. Save same image with OCR/UI tree
|
||||
fake_ocr = [{"text": "hello", "box": [0, 0, 10, 10]}]
|
||||
fake_ui = [{"resource-id": "button1", "text": "click me"}]
|
||||
|
||||
image_name2 = engine.get_or_create_image(image_bytes, ui_tree=fake_ui, ocr_result=fake_ocr)
|
||||
|
||||
assert image_name1 == image_name2
|
||||
|
||||
# Verify it now has OCR/UI tree
|
||||
record2 = engine.storage.get_image(image_name2)
|
||||
assert record2 is not None
|
||||
assert record2.ocr_result == fake_ocr
|
||||
assert record2.ui_tree == fake_ui
|
||||
|
||||
|
||||
def test_create_image_duplicate_handling(tmp_path):
|
||||
from artemis.data_engine.storage import StorageManager
|
||||
from artemis.data_engine.models import ImageRecord
|
||||
|
||||
storage = StorageManager(tmp_path / "data_engine.db", tmp_path)
|
||||
image_record = ImageRecord(
|
||||
image_name="test_image_hash",
|
||||
timestamp=123.456,
|
||||
ocr_result=None,
|
||||
ui_tree=None,
|
||||
extra_metadata={},
|
||||
)
|
||||
|
||||
# First insert
|
||||
storage.create_image(image_record)
|
||||
|
||||
# Second insert with same image_name should not raise IntegrityError due to INSERT OR IGNORE
|
||||
storage.create_image(image_record)
|
||||
|
||||
record = storage.get_image("test_image_hash")
|
||||
assert record is not None
|
||||
assert record.image_name == "test_image_hash"
|
||||
|
||||
|
||||
def test_video_recording_persistence(tmp_path):
|
||||
from uuid import uuid4
|
||||
from artemis.data_engine.storage import StorageManager
|
||||
from artemis.data_engine.models import VideoRecordingRecord
|
||||
|
||||
storage = StorageManager(tmp_path / "data_engine.db", tmp_path)
|
||||
video_id = uuid4()
|
||||
session_id = uuid4()
|
||||
|
||||
record = VideoRecordingRecord(
|
||||
video_id=video_id,
|
||||
session_id=session_id,
|
||||
device_id="emulator-5554",
|
||||
start_time=100.0,
|
||||
end_time=None,
|
||||
local_video_path="/tmp/recording.mp4",
|
||||
)
|
||||
|
||||
# Test insert
|
||||
storage.create_video_recording(record)
|
||||
|
||||
persisted = storage.get_video_recording(video_id)
|
||||
assert persisted is not None
|
||||
assert persisted.video_id == video_id
|
||||
assert persisted.session_id == session_id
|
||||
assert persisted.device_id == "emulator-5554"
|
||||
assert persisted.start_time == 100.0
|
||||
assert persisted.end_time is None
|
||||
assert persisted.local_video_path == "/tmp/recording.mp4"
|
||||
|
||||
# Test update
|
||||
record.end_time = 150.0
|
||||
record.local_video_path = "/tmp/recording_final.mp4"
|
||||
storage.update_video_recording(record)
|
||||
|
||||
updated = storage.get_video_recording(video_id)
|
||||
assert updated is not None
|
||||
assert updated.end_time == 150.0
|
||||
assert updated.local_video_path == "/tmp/recording_final.mp4"
|
||||
|
||||
|
||||
def test_record_step_suppresses_identical_post_screenshot(tmp_path):
|
||||
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
|
||||
|
||||
engine = DataEngine(mock_ctx)
|
||||
engine.start_session("test_goal")
|
||||
same_image_bytes = b"identical_screen_bytes_123"
|
||||
|
||||
step_id = engine.record_step(
|
||||
pre_screenshot_bytes=same_image_bytes,
|
||||
post_screenshot_bytes=same_image_bytes,
|
||||
summary="Test step with identical screenshot",
|
||||
)
|
||||
for t in list(engine._pending_threads):
|
||||
t.join()
|
||||
|
||||
step_record = engine.storage.get_steps(engine.current_session_id)[0]
|
||||
assert step_record is not None
|
||||
assert step_record.pre_image_name is not None
|
||||
# post_image_name must be None when no visual transition occurred
|
||||
assert step_record.post_image_name is None
|
||||
|
||||
|
||||
def test_update_step_execution_result_suppresses_identical_post_screenshot(tmp_path):
|
||||
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
|
||||
|
||||
engine = DataEngine(mock_ctx)
|
||||
engine.start_session("test_goal")
|
||||
image_bytes = b"pre_screen_bytes_456"
|
||||
|
||||
step_id = engine.record_step(
|
||||
pre_screenshot_bytes=image_bytes,
|
||||
summary="Initial step",
|
||||
)
|
||||
|
||||
step_record = engine.storage.get_steps(engine.current_session_id)[0]
|
||||
assert step_record.post_image_name is None
|
||||
|
||||
# Update with post_image_name matching pre_image_name
|
||||
engine.storage.update_step_execution_result(
|
||||
step_id,
|
||||
{"status": "success"},
|
||||
post_image_name=step_record.pre_image_name,
|
||||
)
|
||||
|
||||
updated_step = engine.storage.get_steps(engine.current_session_id)[0]
|
||||
assert updated_step.post_image_name is None
|
||||
|
||||
# Update with distinct post_image_name
|
||||
engine.storage.update_step_execution_result(
|
||||
step_id,
|
||||
{"status": "success"},
|
||||
post_image_name="distinct_new_hash_789",
|
||||
)
|
||||
|
||||
updated_step_distinct = engine.storage.get_steps(engine.current_session_id)[0]
|
||||
assert updated_step_distinct.post_image_name == "distinct_new_hash_789"
|
||||
@@ -0,0 +1,566 @@
|
||||
# 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
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.data_engine.engine import DataEngine
|
||||
from artemis.graph.state import State
|
||||
from artemis.utils.logger import DataEngineHandler
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_artemis_ctx(tmp_path):
|
||||
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
|
||||
mock_ctx.task_plan_snapshot = None
|
||||
return mock_ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_engine_handler_queue_buffering_and_background_worker(
|
||||
mock_artemis_ctx,
|
||||
):
|
||||
"""Verify DataEngineHandler.emit buffers log records via in-memory queue without spawning OS threads per log line."""
|
||||
engine = DataEngine(mock_artemis_ctx)
|
||||
session_id = engine.start_session("Test Log Queue")
|
||||
|
||||
handler = DataEngineHandler()
|
||||
|
||||
with patch("sys.modules") as mock_modules:
|
||||
mock_engine_mod = MagicMock()
|
||||
mock_engine_mod._CURRENT_DATA_ENGINE = engine
|
||||
mock_modules.get.return_value = mock_engine_mod
|
||||
|
||||
# Emit 50 rapid log records
|
||||
for i in range(50):
|
||||
record = logging.LogRecord(
|
||||
name=f"test.logger.{i}",
|
||||
level=logging.INFO,
|
||||
pathname="test.py",
|
||||
lineno=10 + i,
|
||||
msg=f"Async queue test log message {i}",
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
handler.emit(record)
|
||||
|
||||
# Wait for the background worker thread of DataEngineHandler and DataEngine tasks to drain
|
||||
await asyncio.sleep(0.4)
|
||||
if engine._pending_tasks:
|
||||
await asyncio.gather(*engine._pending_tasks)
|
||||
|
||||
# Verify that traces were recorded and persisted accurately via direct SQLite query
|
||||
with engine.storage._get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM traces WHERE session_id = ? AND type = 'log'",
|
||||
(str(session_id),),
|
||||
)
|
||||
log_rows = cursor.fetchall()
|
||||
|
||||
assert len(log_rows) >= 50
|
||||
messages = {json.loads(row["payload"])["message"] for row in log_rows}
|
||||
for i in range(50):
|
||||
assert f"Async queue test log message {i}" in messages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_step_action_and_metadata_background_offloading(mock_artemis_ctx, tmp_path):
|
||||
"""Verify that update_step_* methods publish SSE events instantly in memory and offload SQLite & disk I/O to background."""
|
||||
engine = DataEngine(mock_artemis_ctx)
|
||||
session_id = engine.start_session("Test Step Offloading")
|
||||
step_id = engine.allocate_step_id()
|
||||
|
||||
# Create the initial step
|
||||
engine.record_step(summary="Initial step summary")
|
||||
if engine._pending_tasks:
|
||||
await asyncio.gather(*engine._pending_tasks)
|
||||
|
||||
# Mock _publish to verify immediate real-time SSE delivery
|
||||
published_events = []
|
||||
engine._publish = lambda event_type, payload: published_events.append((event_type, payload))
|
||||
|
||||
fake_action = {"action": "tap", "coordinates": [100, 200]}
|
||||
fake_post_jpg = b"fake_post_screenshot_jpg_bytes_12345"
|
||||
|
||||
# Call update_step_action with post screenshot bytes
|
||||
engine.update_step_action(fake_action, post_screenshot_bytes=fake_post_jpg)
|
||||
|
||||
# Verify SSE was published immediately before/during task execution
|
||||
assert len(published_events) == 1
|
||||
assert published_events[0][0] == "step_updated"
|
||||
assert published_events[0][1]["action_taken"] == fake_action
|
||||
|
||||
# Call other update_step_* methods
|
||||
engine.update_step_summary(step_id, "Updated concise summary")
|
||||
engine.update_step_thinking(step_id, "Raw thinking process...")
|
||||
engine.update_step_native_thinking(step_id, "Native thinking process...")
|
||||
engine.update_step_execution_result(
|
||||
step_id, {"status": "success"}, post_image_name="hash_post_123"
|
||||
)
|
||||
|
||||
# Now await all background pending tasks
|
||||
if engine._pending_tasks:
|
||||
await asyncio.gather(*engine._pending_tasks)
|
||||
|
||||
# Verify SQLite record has been correctly updated by background tasks
|
||||
steps = engine.storage.get_steps(session_id)
|
||||
step_record = next((s for s in steps if s.step_id == step_id), None)
|
||||
assert step_record is not None
|
||||
assert step_record.action_taken == fake_action
|
||||
assert step_record.summary == "Updated concise summary"
|
||||
assert step_record.operator_raw_thinking == "Raw thinking process..."
|
||||
assert step_record.operator_native_thinking == "Native thinking process..."
|
||||
assert step_record.last_execution_result == {"status": "success"}
|
||||
assert step_record.post_image_name == "hash_post_123"
|
||||
|
||||
# Verify post.jpg was written to disk inside step directory
|
||||
post_jpg_path = engine.current_step_dir / "post.jpg"
|
||||
assert post_jpg_path.exists()
|
||||
assert post_jpg_path.read_bytes() == fake_post_jpg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_step_background_image_and_step_persistence(
|
||||
mock_artemis_ctx,
|
||||
):
|
||||
"""Verify record_step immediately calculates SHA-256 hashes and offloads get_or_create_image + create_step to background."""
|
||||
engine = DataEngine(mock_artemis_ctx)
|
||||
session_id = engine.start_session("Test Record Step Image Offloading")
|
||||
step_id = engine.allocate_step_id()
|
||||
|
||||
pre_img_bytes = b"fake_pre_screenshot_data_001"
|
||||
post_img_bytes = b"fake_post_screenshot_data_002"
|
||||
expected_pre_hash = hashlib.sha256(pre_img_bytes).hexdigest()
|
||||
expected_post_hash = hashlib.sha256(post_img_bytes).hexdigest()
|
||||
|
||||
returned_step_id = engine.record_step(
|
||||
pre_screenshot_bytes=pre_img_bytes,
|
||||
post_screenshot_bytes=post_img_bytes,
|
||||
summary="Testing background image persistence",
|
||||
)
|
||||
assert returned_step_id == step_id
|
||||
|
||||
# Await background storage tasks
|
||||
if engine._pending_tasks:
|
||||
await asyncio.gather(*engine._pending_tasks)
|
||||
|
||||
# Verify image records were created in SQLite
|
||||
pre_record = engine.storage.get_image(expected_pre_hash)
|
||||
post_record = engine.storage.get_image(expected_post_hash)
|
||||
assert pre_record is not None
|
||||
assert pre_record.image_name == expected_pre_hash
|
||||
assert post_record is not None
|
||||
assert post_record.image_name == expected_post_hash
|
||||
|
||||
# Verify image files exist on disk
|
||||
pre_path = engine.get_image_path(expected_pre_hash)
|
||||
post_path = engine.get_image_path(expected_post_hash)
|
||||
assert pre_path.exists() and pre_path.read_bytes() == pre_img_bytes
|
||||
assert post_path.exists() and post_path.read_bytes() == post_img_bytes
|
||||
|
||||
# Verify StepRecord has correct hashes and summary
|
||||
steps = engine.storage.get_steps(session_id)
|
||||
step_record = next((s for s in steps if s.step_id == step_id), None)
|
||||
assert step_record is not None
|
||||
assert step_record.pre_image_name == expected_pre_hash
|
||||
assert step_record.post_image_name == expected_post_hash
|
||||
assert step_record.summary == "Testing background image persistence"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_perception_node_async_offloading(mock_artemis_ctx, tmp_path):
|
||||
"""Verify perception_node offloads injected_instruction reading/unlinking and get_or_create_image to background."""
|
||||
from artemis.graph.perception import perception_node
|
||||
|
||||
engine = DataEngine(mock_artemis_ctx)
|
||||
engine.start_session("Test Perception Node Offloading")
|
||||
mock_artemis_ctx.data_engine = engine
|
||||
|
||||
# Create an injected_instruction.json file
|
||||
instruction_file = Path(engine.base_dir) / "injected_instruction.json"
|
||||
instruction_file.write_text(json.dumps({"instruction": "Avoid clicking Ads"}), encoding="utf-8")
|
||||
|
||||
# Prepare mock controller
|
||||
fake_screenshot_bytes = b"fake_screenshot_for_perception_node_123"
|
||||
fake_screenshot_b64 = base64.b64encode(fake_screenshot_bytes).decode("utf-8")
|
||||
expected_hash = hashlib.sha256(fake_screenshot_bytes).hexdigest()
|
||||
|
||||
mock_screen_data = MagicMock()
|
||||
mock_screen_data.base64 = fake_screenshot_b64
|
||||
mock_screen_data.elements = [{"resource-id": "app_bar", "bounds": "[0,0][1080,200]"}]
|
||||
mock_screen_data.width = 1080
|
||||
mock_screen_data.height = 2400
|
||||
|
||||
with (
|
||||
patch("artemis.graph.perception.UnifiedMobileController") as mock_controller_cls,
|
||||
patch("artemis.graph.perception.perform_ocr", new_callable=AsyncMock) as mock_ocr,
|
||||
patch("artemis.graph.perception._detect_status_bar_height", return_value=0),
|
||||
patch("artemis.graph.perception._should_skip_settling", return_value=True),
|
||||
):
|
||||
mock_controller = mock_controller_cls.return_value
|
||||
mock_controller.get_screen_data = AsyncMock(return_value=mock_screen_data)
|
||||
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,
|
||||
)
|
||||
|
||||
update = await perception_node(state, mock_artemis_ctx)
|
||||
|
||||
# Verify perception_node returned expected update immediately
|
||||
assert update["injected_instruction"] == "Avoid clicking Ads"
|
||||
assert update["latest_screenshot"] == str(engine.get_image_path(expected_hash))
|
||||
assert update["operator_raw_data"]["width"] == 1080
|
||||
|
||||
# Verify injected_instruction.json was unlinked
|
||||
assert not instruction_file.exists()
|
||||
|
||||
# Await background tasks triggered by perception_node
|
||||
await asyncio.sleep(0.1)
|
||||
if engine._pending_tasks:
|
||||
await asyncio.gather(*engine._pending_tasks)
|
||||
|
||||
# Verify image record and file were created in background by perception_node
|
||||
img_record = engine.storage.get_image(expected_hash)
|
||||
assert img_record is not None
|
||||
assert img_record.image_name == expected_hash
|
||||
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."""
|
||||
import httpx
|
||||
from artemis.utils import ocr_api
|
||||
|
||||
# Reset singleton to test fresh creation & persistence
|
||||
ocr_api._HTTP_CLIENT = None
|
||||
client1 = ocr_api.get_http_client()
|
||||
client2 = ocr_api.get_http_client()
|
||||
|
||||
assert isinstance(client1, httpx.AsyncClient)
|
||||
assert client1 is client2
|
||||
|
||||
# Verify perform_ocr uses this exact persistent client
|
||||
fake_ocr_resp = MagicMock()
|
||||
fake_ocr_resp.status_code = 200
|
||||
fake_ocr_resp.json.return_value = {
|
||||
"responses": [
|
||||
{
|
||||
"textAnnotations": [
|
||||
{
|
||||
"description": "Combined text block",
|
||||
"boundingPoly": {"vertices": []},
|
||||
},
|
||||
{
|
||||
"description": "Hello World",
|
||||
"boundingPoly": {"vertices": [{"x": 10, "y": 20}, {"x": 100, "y": 200}]},
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(client1, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = fake_ocr_resp
|
||||
result = await ocr_api.perform_ocr(screenshot_b64="ZHVtbXlfYjY0")
|
||||
|
||||
mock_post.assert_called_once()
|
||||
assert len(result) == 1
|
||||
assert result[0]["text"] == "Hello World"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_filter_and_ui_automator_client_pre_parsed_bounds_o1_lookup():
|
||||
"""Verify XML parsing pre-populates parsed_bounds and _parse_bounds hits this cache in O(1) without regex matches."""
|
||||
import re
|
||||
from artemis.clients.ui_automator_client import _parse_hierarchy_xml_to_elements
|
||||
from artemis.utils.ui_filter import _parse_bounds
|
||||
|
||||
# 1. Test ingestion pre-parsing in _parse_hierarchy_xml_to_elements
|
||||
xml_data = '<hierarchy><node text="Login Btn" bounds="[15,25][350,120]"/></hierarchy>'
|
||||
elements = _parse_hierarchy_xml_to_elements(xml_data)
|
||||
assert len(elements) == 1
|
||||
assert elements[0]["parsed_bounds"] == {
|
||||
"left": 15,
|
||||
"top": 25,
|
||||
"right": 350,
|
||||
"bottom": 120,
|
||||
}
|
||||
|
||||
# 2. Test _parse_bounds O(1) hit vs fallback
|
||||
with patch.object(re, "match", wraps=re.match) as spy_match:
|
||||
# Hit pre-parsed bounds -> re.match MUST NOT be called
|
||||
hit_result = _parse_bounds(elements[0])
|
||||
assert hit_result == {
|
||||
"left": 15,
|
||||
"top": 25,
|
||||
"right": 350,
|
||||
"bottom": 120,
|
||||
}
|
||||
spy_match.assert_not_called()
|
||||
|
||||
# Fallback raw string -> re.match is called exactly once
|
||||
fallback_result = _parse_bounds("[50,60][150,160]")
|
||||
assert fallback_result == {
|
||||
"left": 50,
|
||||
"top": 60,
|
||||
"right": 150,
|
||||
"bottom": 160,
|
||||
}
|
||||
assert spy_match.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_pre_execution_loop_reverted_to_exact_safety_contract(
|
||||
mock_artemis_ctx, tmp_path
|
||||
):
|
||||
"""Verify Validator pre-execution check runs VLM on XML failure, but retains XML error if VLM fails (reverted contract)."""
|
||||
from artemis.agents.validator.validator import ValidatorNode, ValidationErrorCategory
|
||||
|
||||
# Set mock context properties required by tracing decorators
|
||||
mock_artemis_ctx.data_engine = MagicMock()
|
||||
|
||||
node = ValidatorNode(mock_artemis_ctx)
|
||||
session = MagicMock()
|
||||
session.call_tool = AsyncMock(
|
||||
return_value=MagicMock(content=[MagicMock(text="ZHVtbXlfZGF0YQ==")])
|
||||
)
|
||||
|
||||
decisions = json.dumps(
|
||||
[
|
||||
{
|
||||
"action": "tap",
|
||||
"coordinates": [200, 250],
|
||||
"target_text": "Login",
|
||||
"target_bounds": [100, 200, 300, 300],
|
||||
"target_resource_id": "btn_login",
|
||||
}
|
||||
]
|
||||
)
|
||||
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 (
|
||||
patch.object(
|
||||
ValidatorNode, "_get_initial_screenshot", new_callable=AsyncMock
|
||||
) as mock_get_screen,
|
||||
patch.object(
|
||||
ValidatorNode, "_validate_action_precondition", new_callable=AsyncMock
|
||||
) as mock_xml,
|
||||
patch.object(
|
||||
ValidatorNode,
|
||||
"_validate_action_precondition_pixel",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_pixel,
|
||||
patch(
|
||||
"artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_analyze,
|
||||
):
|
||||
mock_get_screen.return_value = ("dummy_b64", "dummy_stem")
|
||||
# 1. XML check returns failure (TARGET_OCCUPIED by 'Sign Up' button)
|
||||
mock_xml.return_value = (
|
||||
False,
|
||||
ValidationErrorCategory.TARGET_OCCUPIED,
|
||||
"Occupied by Sign Up button",
|
||||
)
|
||||
# 2. VLM check returns failure (PIXEL_TARGET_DISAPPEARED)
|
||||
mock_pixel.return_value = (
|
||||
False,
|
||||
ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED,
|
||||
"VLM target missing",
|
||||
)
|
||||
|
||||
mock_analyze.return_value = {
|
||||
"status": "cannot_fix",
|
||||
"analysis": "Unrepairable drift",
|
||||
}
|
||||
|
||||
result = await node(state)
|
||||
|
||||
# Verify both XML check and VLM check were called (reverted double-check contract)
|
||||
mock_xml.assert_called_once()
|
||||
mock_pixel.assert_called_once()
|
||||
|
||||
# Verify FailureAnalyzer received the EXACT XML error (TARGET_OCCUPIED), NOT the VLM error
|
||||
mock_analyze.assert_called_once()
|
||||
args, kwargs = mock_analyze.call_args
|
||||
assert kwargs.get("error_category") == ValidationErrorCategory.TARGET_OCCUPIED
|
||||
assert "Occupied by Sign Up button" in args[2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_analyzer_prune_intermediate_screenshots(mock_artemis_ctx):
|
||||
"""Verify FailureAnalyzer._prune_intermediate_screenshots keeps initial reference images and the latest live image, while pruning intermediate images."""
|
||||
from google.genai import types
|
||||
from artemis.agents.validator.failure_analyzer import FailureAnalyzer
|
||||
|
||||
analyzer = FailureAnalyzer(mock_artemis_ctx)
|
||||
|
||||
# Build simulated contents history:
|
||||
# 0: User (Initial reference screenshots + prompt)
|
||||
# 1: Model
|
||||
# 2: User (Intermediate tool results)
|
||||
# 3: User (Intermediate observation screenshot - SHOULD BE PRUNED)
|
||||
# 4: Model
|
||||
# 5: User (Intermediate tool results 2)
|
||||
# 6: User (Latest observation screenshot - SHOULD BE PRESERVED)
|
||||
contents = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text="Reference screenshot 1"),
|
||||
types.Part.from_bytes(data=b"initial_pre_image_bytes", mime_type="image/jpeg"),
|
||||
types.Part.from_bytes(data=b"initial_post_image_bytes", mime_type="image/jpeg"),
|
||||
],
|
||||
),
|
||||
types.Content(role="model", parts=[types.Part.from_text(text="Clicked something")]),
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[types.Part.from_text(text="Result text")],
|
||||
),
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text="Intermediate observation"),
|
||||
types.Part.from_bytes(
|
||||
data=b"intermediate_image_bytes_to_prune",
|
||||
mime_type="image/jpeg",
|
||||
),
|
||||
],
|
||||
),
|
||||
types.Content(role="model", parts=[types.Part.from_text(text="Clicking again")]),
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[types.Part.from_text(text="Result text 2")],
|
||||
),
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text="Latest live observation"),
|
||||
types.Part.from_bytes(
|
||||
data=b"latest_live_image_bytes_to_keep",
|
||||
mime_type="image/jpeg",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
analyzer._prune_intermediate_screenshots(contents)
|
||||
|
||||
# 1. Verify index 0 images are untouched
|
||||
assert contents[0].parts[1].inline_data.data == b"initial_pre_image_bytes"
|
||||
assert contents[0].parts[2].inline_data.data == b"initial_post_image_bytes"
|
||||
|
||||
# 2. Verify index 3 (intermediate image) is replaced by text placeholder
|
||||
assert getattr(contents[3].parts[1], "inline_data", None) is None
|
||||
assert "[Screenshot of intermediate step omitted for performance]" in contents[3].parts[1].text
|
||||
|
||||
# 3. Verify index 6 (latest image) is fully preserved
|
||||
assert contents[6].parts[1].inline_data.data == b"latest_live_image_bytes_to_keep"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_step_pre_allocated_id_reset(mock_artemis_ctx):
|
||||
"""Verify that after record_step is called, the pre-allocated current_step_id is reset to None so subsequent steps generate distinct IDs."""
|
||||
from artemis.data_engine.engine import DataEngine
|
||||
|
||||
engine = DataEngine(mock_artemis_ctx)
|
||||
engine.start_session("Pre-allocated ID Reset Test")
|
||||
|
||||
# 1. Pre-allocate ID for step 1
|
||||
step_id_1 = engine.allocate_step_id()
|
||||
assert engine.current_step_id == step_id_1
|
||||
|
||||
# 2. Record step 1 (should use step_id_1 and reset current_step_id to None)
|
||||
returned_id_1 = engine.record_step(summary="Step 1")
|
||||
assert returned_id_1 == step_id_1
|
||||
assert engine.current_step_id is None
|
||||
|
||||
# 3. Pre-allocate ID for step 2 (should get a distinct new ID)
|
||||
step_id_2 = engine.allocate_step_id()
|
||||
assert step_id_2 != step_id_1
|
||||
assert engine.current_step_id == step_id_2
|
||||
|
||||
# 4. Record step 2 (should use step_id_2 and reset current_step_id to None)
|
||||
returned_id_2 = engine.record_step(summary="Step 2")
|
||||
assert returned_id_2 == step_id_2
|
||||
assert returned_id_2 != returned_id_1
|
||||
assert engine.current_step_id is None
|
||||
@@ -0,0 +1,189 @@
|
||||
# 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 unittest.mock import MagicMock
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.data_engine.trace import (
|
||||
TraceSpan,
|
||||
smart_serialize,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
def test_smart_serialize():
|
||||
# Test dict
|
||||
d = {"a": 1, "b": "string"}
|
||||
assert smart_serialize(d) == json.dumps(d, ensure_ascii=False)
|
||||
|
||||
# Test list
|
||||
items_list = [1, 2, "three"]
|
||||
assert smart_serialize(items_list) == json.dumps(items_list, ensure_ascii=False)
|
||||
|
||||
# Test bytes
|
||||
b = b"hello world"
|
||||
import hashlib
|
||||
|
||||
expected_sha = hashlib.sha256(b).hexdigest()[:8]
|
||||
assert smart_serialize(b) == f"<Bytes length={len(b)} sha256={expected_sha}>"
|
||||
|
||||
# Test other types
|
||||
assert smart_serialize(123) == "123"
|
||||
assert smart_serialize("just a string") == "just a string"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_span():
|
||||
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||
mock_ctx.data_engine = MagicMock()
|
||||
|
||||
with TraceSpan(name="test_span", ctx=mock_ctx) as span:
|
||||
span.result = "span_result"
|
||||
# Simulate work
|
||||
|
||||
# TraceSpan writes twice: start (running) and end (success)
|
||||
assert mock_ctx.data_engine.record_trace.call_count == 2
|
||||
|
||||
# Verify the final success call (which is the last call)
|
||||
args, kwargs = mock_ctx.data_engine.record_trace.call_args
|
||||
assert kwargs["type"] == "span"
|
||||
assert kwargs["name"] == "test_span"
|
||||
assert kwargs["status"] == "success"
|
||||
assert kwargs["payload"]["result"] == "span_result"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_span_failure():
|
||||
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||
mock_ctx.data_engine = MagicMock()
|
||||
|
||||
try:
|
||||
with TraceSpan(name="failed_span", ctx=mock_ctx):
|
||||
raise ValueError("Span error")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# TraceSpan writes twice: start (running) and end (failed)
|
||||
assert mock_ctx.data_engine.record_trace.call_count == 2
|
||||
|
||||
# Verify the final failed call (which is the last call)
|
||||
args, kwargs = mock_ctx.data_engine.record_trace.call_args
|
||||
assert kwargs["type"] == "span"
|
||||
assert kwargs["name"] == "failed_span"
|
||||
assert kwargs["status"] == "failed"
|
||||
assert "error" in kwargs["payload"]
|
||||
assert "Span error" in kwargs["payload"]["error"]
|
||||
|
||||
|
||||
def test_data_engine_callback_handler():
|
||||
from uuid import uuid4
|
||||
from langchain_core.messages import HumanMessage, AIMessage
|
||||
from artemis.data_engine.trace import DataEngineCallbackHandler
|
||||
|
||||
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||
mock_ctx.data_engine = MagicMock()
|
||||
|
||||
mock_step_id = uuid4()
|
||||
mock_ctx.data_engine.current_step_id = mock_step_id
|
||||
|
||||
handler = DataEngineCallbackHandler(mock_ctx)
|
||||
run_id = uuid4()
|
||||
|
||||
# 1. Simulate on_chat_model_start
|
||||
serialized = {"name": "test_model"}
|
||||
messages = [[HumanMessage(content="Hello LLM")]]
|
||||
handler.on_chat_model_start(serialized, messages, run_id=run_id)
|
||||
|
||||
assert mock_ctx.data_engine.record_trace.call_count == 1
|
||||
call1_args, call1_kwargs = mock_ctx.data_engine.record_trace.call_args_list[0]
|
||||
assert call1_kwargs["type"] == "llm_call"
|
||||
assert call1_kwargs["name"] == "test_model"
|
||||
assert call1_kwargs["status"] == "running"
|
||||
assert call1_kwargs["step_id"] == mock_step_id
|
||||
assert call1_kwargs["payload"]["messages"][0]["content"] == "Hello LLM"
|
||||
|
||||
# 2. Simulate on_llm_end (successful complete response)
|
||||
class MockGeneration:
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
class MockLLMResult:
|
||||
def __init__(self, generations):
|
||||
self.generations = generations
|
||||
|
||||
response_message = AIMessage(content="Hello User")
|
||||
mock_response = MockLLMResult(generations=[[MockGeneration(response_message)]])
|
||||
|
||||
handler.on_llm_end(mock_response, run_id=run_id)
|
||||
|
||||
assert mock_ctx.data_engine.record_trace.call_count == 3
|
||||
call2_args, call2_kwargs = mock_ctx.data_engine.record_trace.call_args_list[1]
|
||||
assert call2_kwargs["type"] == "llm_call"
|
||||
assert call2_kwargs["status"] == "success"
|
||||
assert call2_kwargs["step_id"] == mock_step_id
|
||||
assert call2_kwargs["payload"]["messages"][0]["content"] == "Hello LLM"
|
||||
assert call2_kwargs["payload"]["response"][0]["content"] == "Hello User"
|
||||
assert isinstance(call2_kwargs.get("duration"), float)
|
||||
assert call2_kwargs["duration"] >= 0.0
|
||||
|
||||
call3_args, call3_kwargs = mock_ctx.data_engine.record_trace.call_args_list[2]
|
||||
assert call3_kwargs["type"] == "raw_thinking"
|
||||
assert call3_kwargs["parent_trace_id"] == run_id
|
||||
assert call3_kwargs["payload"]["thought"] == "Hello User"
|
||||
|
||||
|
||||
def test_callback_handler_chain_parenting():
|
||||
from uuid import uuid4
|
||||
from langchain_core.messages import HumanMessage
|
||||
from artemis.data_engine.trace import DataEngineCallbackHandler, CURRENT_TRACE_ID
|
||||
|
||||
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||
mock_ctx.data_engine = MagicMock()
|
||||
|
||||
handler = DataEngineCallbackHandler(mock_ctx)
|
||||
|
||||
agent_run_id = uuid4()
|
||||
chain1_run_id = uuid4()
|
||||
chain2_run_id = uuid4()
|
||||
llm_run_id = uuid4()
|
||||
|
||||
token = CURRENT_TRACE_ID.set(agent_run_id)
|
||||
try:
|
||||
# Simulate chain start (top level, parent is agent_run_id)
|
||||
handler.on_chain_start({}, {}, run_id=chain1_run_id, parent_run_id=None)
|
||||
# Simulate nested chain start (parent is chain1_run_id)
|
||||
handler.on_chain_start({}, {}, run_id=chain2_run_id, parent_run_id=chain1_run_id)
|
||||
|
||||
# Simulate LLM start inside nested chain (parent is chain2_run_id)
|
||||
messages = [[HumanMessage(content="Hello Nested LLM")]]
|
||||
handler.on_chat_model_start(
|
||||
{"name": "test_nested_model"},
|
||||
messages,
|
||||
run_id=llm_run_id,
|
||||
parent_run_id=chain2_run_id,
|
||||
)
|
||||
|
||||
# Verify parent trace ID resolves to agent_run_id (since chains are not recorded)
|
||||
assert mock_ctx.data_engine.record_trace.call_count == 1
|
||||
args, kwargs = mock_ctx.data_engine.record_trace.call_args
|
||||
assert kwargs["parent_trace_id"] == agent_run_id
|
||||
|
||||
# Cleanup
|
||||
handler.on_chain_end({}, run_id=chain2_run_id)
|
||||
handler.on_chain_end({}, run_id=chain1_run_id)
|
||||
|
||||
assert len(handler.chain_parents) == 0
|
||||
finally:
|
||||
CURRENT_TRACE_ID.reset(token)
|
||||
Reference in new issue
Block a user