# 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 AsyncMock, MagicMock, patch from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from langchain_core.tools import StructuredTool import pytest from artemis.agents.checker.checker import ( CheckReport, CheckVerdict, _normalize_report, _run_check_loop, assemble_checker_prompt_segments, build_checker_tools, build_probe_argv, run_checkpoint_check, run_final_check, verdicts_allow_release, ) from artemis.context import ArtemisContext, ExecutionSetup from artemis.core.tool_failure import ToolFailure from artemis.graph.checkpoints import EvidenceAnchor from artemis.utils.plan_grammar import CheckItem def _ci(kind="verify", when="on_complete", text="expected state", parent="p"): return CheckItem(kind=kind, when=when, text=text, parent_key=parent) def _mock_ctx(**setup_kwargs): ctx = MagicMock(spec=ArtemisContext) ctx.execution_setup = ExecutionSetup(**setup_kwargs) ctx.data_engine = MagicMock() ctx.data_engine.base_dir = "unused" ctx.data_engine.get_agent_friendly_steps.return_value = [] ctx.data_engine.get_step_number.return_value = 7 return ctx # --- probe_device: enumerated table, programmatic argv (§8 item 12) ------------------ def test_probe_argv_enumerated_kinds_allowed(): assert build_probe_argv("alarms") == ["dumpsys", "alarm"] assert build_probe_argv("battery") == ["dumpsys", "battery"] assert build_probe_argv("foreground") == ["dumpsys", "activity", "activities"] assert build_probe_argv("packages") == ["pm", "list", "packages"] assert build_probe_argv("setting", {"namespace": "system", "key": "screen_brightness"}) == [ "settings", "get", "system", "screen_brightness", ] assert build_probe_argv("content", {"uri": "content://settings/system"}) == [ "content", "query", "--uri", "content://settings/system", ] assert build_probe_argv("prop", {"key": "ro.build.version.sdk"}) == [ "getprop", "ro.build.version.sdk", ] def test_probe_argv_rejects_unknown_kind(): with pytest.raises(ValueError): build_probe_argv("shell") with pytest.raises(ValueError): build_probe_argv("dumpsys") def test_probe_argv_rejects_whitespace_and_metachars(): for bad in ("a key", "key;rm", "key|x", "key$", "key`x`", "key&&y", "a\nb"): with pytest.raises(ValueError): build_probe_argv("prop", {"key": bad}) with pytest.raises(ValueError): build_probe_argv("setting", {"namespace": "system", "key": "a b"}) with pytest.raises(ValueError): build_probe_argv("content", {"uri": "content://a; rm -rf /"}) def test_probe_argv_battery_set_inexpressible(): """Mutating dumpsys subcommands cannot be expressed: no-parameter probes reject every parameter.""" with pytest.raises(ValueError): build_probe_argv("battery", {"extra": "set level 100"}) with pytest.raises(ValueError): build_probe_argv("alarms", {"args": "anything"}) # And the setting namespace is a closed set with pytest.raises(ValueError): build_probe_argv("setting", {"namespace": "battery", "key": "level"}) # --- Release decision is node-side and never rewrites verdicts ----------------------- def test_verdicts_allow_release_semantics(): ok = CheckReport( verdicts=[ CheckVerdict(item_text="a", kind="verify", status="passed", evidence="e"), CheckVerdict(item_text="b", kind="verify", status="inconclusive", evidence="e"), CheckVerdict(item_text="c", kind="assert", status="failed", evidence="e"), ] ) # Assert failures never block release assert verdicts_allow_release(ok) blocked = CheckReport( verdicts=[ CheckVerdict(item_text="a", kind="verify", status="failed", evidence="e"), ] ) assert not verdicts_allow_release(blocked) def test_normalize_report_downgrades_vague_failures_and_fills_missing(): items = [_ci(kind="verify", text="v1"), _ci(kind="assert", text="a1")] report = CheckReport( verdicts=[CheckVerdict(item_text="v1", kind="verify", status="failed", evidence=" ")] ) normalized = _normalize_report(report, items) by_text = {(v.kind, v.item_text): v for v in normalized.verdicts} # Vague failed -> inconclusive (verdict value hygiene, not release logic) assert by_text[("verify", "v1")].status == "inconclusive" # Missing item gets an inconclusive verdict, never a silent pass assert by_text[("assert", "a1")].status == "inconclusive" # --- Assembly: prompt segments x entry x content x config (§8 item 18) --------------- def test_prompt_segments_verify_only_has_no_assert_section(): prompts = { "base_rules": "BASE", "verify_semantics": "VERIFY-SEG", "assert_semantics": "ASSERT-SEG", "anchor_guide": "ANCHOR-SEG", "final_guide": "FINAL-SEG", "probe_guide": "PROBE-SEG", } text = assemble_checker_prompt_segments( "checkpoint", [_ci(kind="verify")], probe_tool_registered=True, prompts=prompts ) assert "VERIFY-SEG" in text assert "ASSERT-SEG" not in text assert "ANCHOR-SEG" in text assert "FINAL-SEG" not in text assert "PROBE-SEG" in text text2 = assemble_checker_prompt_segments( "final", [_ci(kind="assert")], probe_tool_registered=False, prompts=prompts ) assert "ASSERT-SEG" in text2 assert "VERIFY-SEG" not in text2 assert "FINAL-SEG" in text2 assert "ANCHOR-SEG" not in text2 # Probe disabled -> zero mention of probing anywhere in the prompt assert "PROBE-SEG" not in text2 def test_tool_table_probe_gate(): ctx = _mock_ctx(disable_device_probes=False) names = {t.name for t in build_checker_tools(ctx, "checkpoint")} assert "probe_device" in names assert "read_note" in names # The shared history tools, mounted as a set. assert {"search_history", "replay_steps", "get_step_screenshot"} <= names # Never any device action, note write, or sub-agent assert not names & { "save_note", "update_note", "append_note", "click", "swipe", "ask_diagnoser", "ask_explorer", "run_adb_command", } ctx2 = _mock_ctx(disable_device_probes=True) names2 = {t.name for t in build_checker_tools(ctx2, "checkpoint")} assert "probe_device" not in names2 @pytest.mark.asyncio async def test_checkpoint_entry_never_touches_live_screen(): """Evidence discipline: the checkpoint entry must not capture the current screen — its evidence is anchored history plus persistent probes.""" ctx = _mock_ctx() report = CheckReport( verdicts=[CheckVerdict(item_text="x", kind="verify", status="passed", evidence="e")] ) structured = MagicMock() structured.ainvoke = AsyncMock(return_value=report) llm = MagicMock() llm.with_structured_output.return_value = structured response = MagicMock() response.tool_calls = [] with ( patch("artemis.agents.checker.checker.get_llm", return_value=llm), patch( "artemis.agents.checker.checker.acomplete", new=AsyncMock(return_value=response), ), patch( "artemis.agents.checker.checker._capture_final_screen", new=AsyncMock(return_value=(None, "SHOULD NOT BE CALLED")), ) as capture_mock, ): result = await run_checkpoint_check( ctx, check_items=[_ci(text="x")], anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text="- [x] G"), goal="the goal", subgoal_text="G", ) capture_mock.assert_not_awaited() assert result.verdicts[0].status == "passed" @pytest.mark.asyncio async def test_final_entry_captures_live_screen(): ctx = _mock_ctx() report = CheckReport(verdicts=[]) structured = MagicMock() structured.ainvoke = AsyncMock(return_value=report) llm = MagicMock() llm.with_structured_output.return_value = structured response = MagicMock() response.tool_calls = [] with ( patch("artemis.agents.checker.checker.get_llm", return_value=llm), patch( "artemis.agents.checker.checker.acomplete", new=AsyncMock(return_value=response), ), patch( "artemis.agents.checker.checker._capture_final_screen", new=AsyncMock(return_value=("b64img", "elements")), ) as capture_mock, ): await run_final_check( ctx, goal="the goal", plan_text="- [x] G", ledger=[], check_items=[_ci(kind="assert", when="at_end", text="no crash", parent=None)], ) capture_mock.assert_awaited_once() # --- Shared history tools in the check loop ------------------------------------------ def _jpeg_file(tmp_path, color="white"): from PIL import Image path = tmp_path / "pre.jpg" Image.new("RGB", (200, 400), color).save(path, format="JPEG") return path def _screenshot_then_verdict(provider, tool_call=None): """LLM double: first turn asks for a step screenshot (or ``tool_call``), second turn answers.""" from types import SimpleNamespace report = CheckReport( verdicts=[CheckVerdict(item_text="x", kind="verify", status="passed", evidence="e")] ) structured = MagicMock() structured.ainvoke = AsyncMock(return_value=report) llm = MagicMock() llm.endpoint = SimpleNamespace(provider=provider) llm.with_structured_output.return_value = structured ask = MagicMock() ask.tool_calls = [ tool_call or { "name": "get_step_screenshot", "args": {"step_number": 2, "which": "pre"}, "id": "tc-shot", } ] done = MagicMock() done.tool_calls = [] return llm, AsyncMock(side_effect=[ask, done]) async def _run_with_screenshot(tmp_path, provider): path = _jpeg_file(tmp_path) ctx = _mock_ctx() ctx.data_engine.get_step_image_path.return_value = path llm, acomplete = _screenshot_then_verdict(provider) with ( patch("artemis.agents.checker.checker.get_llm", return_value=llm), patch("artemis.agents.checker.checker.acomplete", new=acomplete), ): report = await run_checkpoint_check( ctx, check_items=[_ci(text="x")], anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text="- [x] G"), goal="the goal", subgoal_text="G", ) ctx.data_engine.get_step_image_path.assert_called_once_with(2, "pre") # The message list handed to the second model call carries the tool result. messages = acomplete.call_args_list[1][0][1] return report, messages @pytest.mark.asyncio async def test_step_screenshot_rides_inside_the_tool_message_for_gemini(tmp_path): from langchain_core.messages import HumanMessage, ToolMessage report, messages = await _run_with_screenshot(tmp_path, "google") assert report.verdicts[0].status == "passed" tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] assert len(tool_msgs) == 1 assert tool_msgs[0].tool_call_id == "tc-shot" assert tool_msgs[0].name == "get_step_screenshot" assert tool_msgs[0].status == "success" blocks = tool_msgs[0].content assert blocks[0]["text"] == "Screenshot of step 2 (pre-action) is attached." assert blocks[1]["image_url"]["url"].startswith("data:image/jpeg;base64,") # No extra human turn was spliced in for this provider. assert sum(isinstance(m, HumanMessage) for m in messages) == 1 @pytest.mark.asyncio async def test_step_screenshot_follows_as_a_human_message_for_openai(tmp_path): from langchain_core.messages import HumanMessage, ToolMessage _, messages = await _run_with_screenshot(tmp_path, "openai") tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] assert len(tool_msgs) == 1 assert tool_msgs[0].content == "Screenshot of step 2 (pre-action) is attached." follower = messages[messages.index(tool_msgs[0]) + 1] assert isinstance(follower, HumanMessage) assert follower.content[0]["text"] == "[Screenshot returned by get_step_screenshot for step 2]" assert follower.content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,") @pytest.mark.asyncio async def test_replay_steps_result_is_a_plain_tool_message(tmp_path): from langchain_core.messages import ToolMessage ctx = _mock_ctx() ctx.data_engine.get_agent_friendly_steps_in_range.return_value = [ { "step_id": "s3", "step_number": 3, "relative_time": "9.0s", "summary": "Saved the alarm.", "action_taken": {"action": "click", "target": [0.5, 0.5], "target_text": "Save"}, "last_execution_result": {"status": "success"}, "interleaved_events": [], } ] llm, acomplete = _screenshot_then_verdict( "google", {"name": "replay_steps", "args": {"start_step": 3}, "id": "tc-replay"} ) with ( patch("artemis.agents.checker.checker.get_llm", return_value=llm), patch("artemis.agents.checker.checker.acomplete", new=acomplete), ): await run_checkpoint_check( ctx, check_items=[_ci(text="x")], anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text="- [x] G"), goal="the goal", subgoal_text="G", ) ctx.data_engine.get_agent_friendly_steps_in_range.assert_called_once_with(3, 3) messages = acomplete.call_args_list[1][0][1] tool_msg = next(m for m in messages if isinstance(m, ToolMessage)) assert tool_msg.name == "replay_steps" assert "- **Step 3 (Start: 9.0s)**" in tool_msg.content assert "[Screen]: Saved the alarm." in tool_msg.content assert "[Action]: Tapped 'Save' at [0.5, 0.5]" in tool_msg.content # --- Streamed-reasoning transcript (replay of the live Thought/Work text) ------------- class _FakeBus: """Minimal DataEngine bus: subscribers receive every published event.""" def __init__(self): self.subscribers = [] def subscribe(self, cb): self.subscribers.append(cb) def unsubscribe(self, cb): self.subscribers.remove(cb) def _publish(self, event_type, data): for cb in list(self.subscribers): cb(event_type, data) def _bus_ctx(tmp_path): """Mock engine whose bus actually delivers events (the rest stays mocked).""" ctx = _mock_ctx() ctx.data_engine.base_dir = tmp_path bus = _FakeBus() ctx.data_engine.subscribers = bus.subscribers ctx.data_engine.subscribe = bus.subscribe ctx.data_engine.unsubscribe = bus.unsubscribe ctx.data_engine._publish = bus._publish return ctx def _stream(ctx, exec_id, chunk, *, stream_type="text", parent=None): from artemis.data_engine.context_vars import CURRENT_TRACE_ID ctx.data_engine._publish( "llm_stream", { "execution_id": exec_id, "parent_trace_id": parent or str(CURRENT_TRACE_ID.get()), "chunk": chunk, "stream_type": stream_type, }, ) def _read_streams(tmp_path): from artemis.graph.checkpoints import read_attempt_streams return read_attempt_streams(tmp_path) @pytest.mark.asyncio async def test_checker_persists_its_streamed_turns_as_timestamped_segments(tmp_path): """Every llm_stream chunk emitted under the Checker's own trace is kept as one segment per (turn, kind) in first-chunk order; other agents' streams and a discarded (mid-stream reset) turn are left out.""" ctx = _bus_ctx(tmp_path) async def loop(ctx, messages, tools, items): _stream(ctx, "turn-1", "Looking at ", stream_type="thinking") _stream(ctx, "turn-1", "step 2.", stream_type="thinking") _stream(ctx, "turn-1", "Let me replay it.") _stream(ctx, "operator-turn", "not mine", parent="someone-else") _stream(ctx, "turn-2", "partial ") ctx.data_engine._publish( "llm_stream_reset", {"stream_exec_id": "turn-2", "action": "discard"} ) _stream(ctx, "turn-3", "The alarm is there.") return CheckReport(verdicts=[]) with ( patch("artemis.agents.checker.checker._run_check_loop", side_effect=loop), patch("artemis.agents.checker.checker.build_checker_tools", return_value=[]), ): await run_checkpoint_check( ctx, check_items=[_ci(text="x")], anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text="- [x] G"), goal="the goal", subgoal_text="G", attempt_id="abc#1", ) assert ctx.data_engine.subscribers == [] # detached after the run records = _read_streams(tmp_path) assert len(records) == 1 rec = records[0] assert rec["attempt_id"] == "abc#1" assert rec["checkpoint_id"] == "abc" assert rec["phase"] == "checkpoint" assert rec["trace_id"] assert rec["truncated"] is False and rec["dropped_chars"] == 0 assert isinstance(rec["ts"], float) segs = rec["segments"] assert [(s["execution_id"], s["role"], s["text"]) for s in segs] == [ ("turn-1", "thought", "Looking at step 2."), ("turn-1", "answer", "Let me replay it."), ("turn-3", "answer", "The alarm is there."), ] assert all(isinstance(s["when"], float) for s in segs) assert segs[0]["when"] <= segs[1]["when"] <= segs[2]["when"] @pytest.mark.asyncio async def test_final_entry_persists_transcript_even_when_the_loop_fails(tmp_path): """The transcript is written in a ``finally``: an attempt that times out, raises or is superseded still leaves what it said.""" ctx = _bus_ctx(tmp_path) async def loop(ctx, messages, tools, items): _stream(ctx, "turn-1", "Checking the final screen…") raise RuntimeError("provider down") with ( patch("artemis.agents.checker.checker._run_check_loop", side_effect=loop), patch("artemis.agents.checker.checker.build_checker_tools", return_value=[]), patch( "artemis.agents.checker.checker._capture_final_screen", AsyncMock(return_value=(None, "")), ), pytest.raises(RuntimeError), ): await run_final_check( ctx, goal="g", plan_text="- [x] G", ledger=[], check_items=[_ci(text="x")], attempt_id="final#1", ) records = _read_streams(tmp_path) assert [r["attempt_id"] for r in records] == ["final#1"] assert records[0]["phase"] == "final" and records[0]["checkpoint_id"] == "final" assert records[0]["segments"][0]["text"] == "Checking the final screen…" @pytest.mark.asyncio async def test_no_transcript_without_attempt_id_or_streamed_text(tmp_path): ctx = _bus_ctx(tmp_path) async def silent(ctx, messages, tools, items): return CheckReport(verdicts=[]) async def talkative(ctx, messages, tools, items): _stream(ctx, "turn-1", "text") return CheckReport(verdicts=[]) with patch("artemis.agents.checker.checker.build_checker_tools", return_value=[]): with patch("artemis.agents.checker.checker._run_check_loop", side_effect=talkative): await run_checkpoint_check( ctx, check_items=[_ci(text="x")], anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text=""), goal="g", subgoal_text="G", ) with patch("artemis.agents.checker.checker._run_check_loop", side_effect=silent): await run_checkpoint_check( ctx, check_items=[_ci(text="x")], anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text=""), goal="g", subgoal_text="G", attempt_id="abc#2", ) assert _read_streams(tmp_path) == [] assert not (tmp_path / "check_streams.jsonl").exists() # --- Probe failures are structural; history rows keep target provenance --------------- @pytest.mark.asyncio async def test_probe_device_invalid_kind_is_a_tool_failure(): """A refused probe answers with a ToolFailure, so the tool loop marks the ToolMessage as an error structurally rather than by sniffing its wording.""" from artemis.agents.checker.checker import get_probe_tool from artemis.core.tool_failure import is_tool_failure tool = get_probe_tool(_mock_ctx(disable_device_probes=False)) result = await tool.ainvoke({"kind": "shell", "params": None}) assert is_tool_failure(result) assert "Error" in str(result) @pytest.mark.asyncio async def test_probe_device_execution_error_is_a_tool_failure(): from artemis.agents.checker.checker import get_probe_tool from artemis.core.tool_failure import is_tool_failure ctx = _mock_ctx(disable_device_probes=False) ctx.get_adb_client.side_effect = RuntimeError("no adb") tool = get_probe_tool(ctx) result = await tool.ainvoke({"kind": "battery", "params": None}) assert is_tool_failure(result) assert "no adb" in str(result) def test_format_history_marks_self_described_targets_only(): """A step without a summary renders its action through the shared renderer, so the Checker sees which target labels are the model's own statement.""" from artemis.agents.checker.checker import _format_history from artemis.utils.task_tree import SELF_DESCRIBED_MARKER ctx = _mock_ctx() ctx.data_engine.get_agent_friendly_steps.return_value = [ { "step_number": 1, "relative_time": "T+00:01", "summary": "", "action_taken": { "action": "click", "coordinates": [500, 600], "target_description": "play button", }, }, { "step_number": 2, "relative_time": "T+00:05", "summary": None, "action_taken": { "action": "click", "coordinates": [100, 200], "target_text": "Login", "target_resource_id": "com.app:id/login", }, }, ] text = _format_history(ctx) lines = text.splitlines() assert lines[0].startswith("- Step 1 (T+00:01): ") assert f"'play button' {SELF_DESCRIBED_MARKER}" in lines[0] assert lines[1].startswith("- Step 2 (T+00:05): ") assert "'Login'" in lines[1] assert SELF_DESCRIBED_MARKER not in lines[1] # Neither row is the raw JSON record. assert "target_description" not in text assert "target_resource_id" not in text def test_format_history_uses_session_offsets_and_skips_empty_steps(): """Rows carry the session-relative ``T+mm:ss`` clock every agent shares (from the step timestamp, else from the engine's ``97.7s`` label), and a step with neither summary nor action renders no row.""" from artemis.agents.checker.checker import _format_history ctx = _mock_ctx() ctx.data_engine.session_start_time = 1000.0 ctx.data_engine.get_agent_friendly_steps.return_value = [ {"step_number": 1, "timestamp": 1097.7, "summary": "Opened the app"}, {"step_number": 2, "relative_time": "12.4s", "summary": "", "action_taken": None}, { "step_number": 3, "relative_time": "130.0s", "summary": None, "action_taken": {"action": "click", "coordinates": [1, 2], "target_text": "Login"}, }, ] lines = _format_history(ctx).splitlines() assert len(lines) == 2 assert lines[0] == "- Step 1 (T+01:37): Opened the app" assert lines[1].startswith("- Step 3 (T+02:10): ") assert "'Login'" in lines[1] async def _final_check_human_text(ctx) -> str: report = CheckReport(verdicts=[]) structured = MagicMock() structured.ainvoke = AsyncMock(return_value=report) llm = MagicMock() llm.with_structured_output.return_value = structured response = MagicMock() response.tool_calls = [] acomplete_mock = AsyncMock(return_value=response) with ( patch("artemis.agents.checker.checker.get_llm", return_value=llm), patch("artemis.agents.checker.checker.acomplete", new=acomplete_mock), patch( "artemis.agents.checker.checker._capture_final_screen", new=AsyncMock(return_value=(None, "elements")), ), ): await run_final_check(ctx, goal="the goal", plan_text="- [x] G", ledger=[], check_items=[]) messages = acomplete_mock.await_args.args[1] human = messages[-1] return "\n".join( block["text"] for block in human.content if isinstance(block, dict) and "text" in block ) @pytest.mark.asyncio async def test_final_review_lists_user_guidance_from_the_step_records(): """Every mid-run user instruction reaches the final review, read from the step it was stamped on, so the goal is audited as amended by it.""" ctx = _mock_ctx() ctx.data_engine.get_agent_friendly_steps.return_value = [ {"step_number": 1, "relative_time": "3.0s", "summary": "s1", "extra_metadata": {}}, { "step_number": 2, "relative_time": "65.0s", "summary": "s2", "extra_metadata": {"injected_instruction": "Skip the login"}, }, ] text = await _final_check_human_text(ctx) section = text.split("# User guidance received during the run\n", 1)[1].split("\n\n", 1)[0] assert section == '- Step 2 (T+01:05): "Skip the login"' # The system prompt audits the goal as amended by that guidance. from artemis.agents.checker.checker import _load_prompts assert "as amended by the user guidance" in _load_prompts()["final_guide"] @pytest.mark.asyncio async def test_final_review_reports_no_guidance_explicitly(): ctx = _mock_ctx() text = await _final_check_human_text(ctx) assert "# User guidance received during the run\n(none)\n" in text # --- tool results: status is structural, never sniffed from the words ----------------- # A helper tool reports failure structurally (``ToolFailure``); free-form text # that merely starts with "Error" is an ordinary answer. _STATUS_CASES = [ pytest.param(ToolFailure("Error: note 'progress' not found"), "error", id="tool_failure"), pytest.param("Error 404 was typed into the search box", "success", id="plain_error_text"), ] def _text_tool(name: str, result): async def _run(key: str) -> str: return result return StructuredTool.from_function(coroutine=_run, name=name, description=name) @pytest.mark.asyncio @pytest.mark.parametrize("result, expected_status", _STATUS_CASES) async def test_check_loop_tool_message_status_is_structural(result, expected_status): ctx = _mock_ctx() tool = _text_tool("read_note", result) tool_turn = AIMessage( content="", tool_calls=[{"name": "read_note", "args": {"key": "progress"}, "id": "c1"}] ) final_turn = AIMessage(content="done", tool_calls=[]) llm = MagicMock() llm.bind_tools.return_value = llm llm.with_structured_output.return_value.ainvoke = AsyncMock( return_value=CheckReport(verdicts=[]) ) messages = [SystemMessage(content="s"), HumanMessage(content="h")] with ( patch("artemis.agents.checker.checker.get_llm", return_value=llm), patch( "artemis.agents.checker.checker.acomplete", new=AsyncMock(side_effect=[tool_turn, final_turn]), ), ): await _run_check_loop(ctx, messages, [tool], check_items=[]) tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] assert len(tool_msgs) == 1 assert tool_msgs[0].tool_call_id == "c1" assert tool_msgs[0].status == expected_status assert tool_msgs[0].content == str(result)