Report device actions as dispatched and preserve full failure details. Route Flash through McpActionExecutor and use ToolFailure across helper tools and agent loops instead of inferring failure from response text. Require model-provided descriptions for coordinate targets. Keep observed element metadata separate from those descriptions in safety checks, visual summaries, history, and frontend labels. Add Gemini Interactions API video analysis with configurable processing mode, chunk size, and timeouts. Centralize Google provider capabilities and token usage normalization, and update google-genai to 2.22.0. Preserve system instructions on follow-up interactions, replan pending chunks when falling back to static processing, and reject unanswered native video and audio segments instead of caching placeholder results. Keep helper-tool failure status intact through history tools and committee calls. Validation: 1,762 Python unit tests passed (3 skipped), 40 frontend formatter tests passed, and the production UI build completed. Ruff lint, formatting, the quality ratchet, and git diff checks passed.
1087 lines
41 KiB
Python
1087 lines
41 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Agentic video understanding sub-agent (Gemini Interactions API)."""
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from artemis.agents.video_analyzer import chunk_agentic
|
|
from artemis.agents.video_analyzer.chunk_agentic import (
|
|
_drive_agentic_loop,
|
|
build_interactions_submit_tool,
|
|
run_agentic_chunk_conversation,
|
|
)
|
|
from artemis.agents.video_analyzer.chunk_conversation import (
|
|
_MISSING_SUMMARY_ERROR,
|
|
_NO_TOOL_CALL_ERROR,
|
|
_WRONG_TOOL_ERROR,
|
|
SubAgentAnswerExhausted,
|
|
_ChunkMedia,
|
|
)
|
|
from artemis.agents.video_analyzer.reliability import (
|
|
AgenticVideoDegraded,
|
|
VideoFailureCategory,
|
|
classify_video_failure,
|
|
is_agentic_rejection,
|
|
)
|
|
from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer
|
|
from artemis.context import ArtemisContext
|
|
from pydantic import SecretStr
|
|
import pytest
|
|
|
|
# --------------------------------------------------------------------- fakes
|
|
|
|
|
|
def _context(model: str = "gemini-3.8-flash", **video_cfg) -> MagicMock:
|
|
ctx = MagicMock(spec=ArtemisContext)
|
|
ctx._video_blackboard = None
|
|
ctx._video_circuit_breaker = None
|
|
ctx._mobile_controller = None
|
|
ctx._genai_client = 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,
|
|
**video_cfg,
|
|
)
|
|
)
|
|
ctx.device = SimpleNamespace(device_id="agentic-device")
|
|
ctx.llm_config = MagicMock()
|
|
ctx.llm_config.utils.video_analyzer.model = model
|
|
ctx.llm_config.utils.video_analyzer.temperature = 0.2
|
|
return ctx
|
|
|
|
|
|
def _function_call(call_id: str, arguments: dict, name: str = "submit_answer"):
|
|
return SimpleNamespace(type="function_call", id=call_id, name=name, arguments=arguments)
|
|
|
|
|
|
def _model_output(text: str):
|
|
return SimpleNamespace(type="model_output", content=[SimpleNamespace(type="text", text=text)])
|
|
|
|
|
|
_USAGE = SimpleNamespace(
|
|
total_input_tokens=191,
|
|
total_output_tokens=655,
|
|
total_tokens=10571,
|
|
total_cached_tokens=0,
|
|
total_thought_tokens=0,
|
|
total_tool_use_tokens=9725,
|
|
)
|
|
|
|
|
|
def _interaction(interaction_id: str, steps: list, status: str = "requires_action"):
|
|
return SimpleNamespace(id=interaction_id, status=status, steps=steps, usage=_USAGE)
|
|
|
|
|
|
def _events(interaction, processing_calls: int = 2, *, argument_chunks: int = 1):
|
|
"""Scripts the SSE stream the real API emits for ``interaction``.
|
|
|
|
Mirrors production: processing steps arrive as start/delta/stop triples,
|
|
function-call arguments arrive only as ``arguments_delta`` JSON text, and
|
|
``interaction.completed`` carries no steps.
|
|
"""
|
|
events = [
|
|
SimpleNamespace(
|
|
event_type="interaction.created",
|
|
interaction=SimpleNamespace(id=interaction.id, status="in_progress"),
|
|
)
|
|
]
|
|
index = 0
|
|
for _ in range(processing_calls):
|
|
events.append(
|
|
SimpleNamespace(
|
|
event_type="step.start",
|
|
index=index,
|
|
step=SimpleNamespace(type="processing_call", id=f"proc_{index}"),
|
|
)
|
|
)
|
|
events.append(SimpleNamespace(event_type="step.stop", index=index, usage=None))
|
|
index += 1
|
|
for step in interaction.steps:
|
|
if step.type == "function_call":
|
|
events.append(
|
|
SimpleNamespace(
|
|
event_type="step.start",
|
|
index=index,
|
|
step=SimpleNamespace(
|
|
type="function_call", id=step.id, name=step.name, arguments={}
|
|
),
|
|
)
|
|
)
|
|
payload = json.dumps(step.arguments)
|
|
size = max(1, len(payload) // argument_chunks)
|
|
for start in range(0, len(payload), size):
|
|
events.append(
|
|
SimpleNamespace(
|
|
event_type="step.delta",
|
|
index=index,
|
|
delta=SimpleNamespace(
|
|
type="arguments_delta", arguments=payload[start : start + size]
|
|
),
|
|
)
|
|
)
|
|
elif step.type == "model_output":
|
|
events.append(
|
|
SimpleNamespace(
|
|
event_type="step.start",
|
|
index=index,
|
|
step=SimpleNamespace(type="model_output", content=[]),
|
|
)
|
|
)
|
|
for content in step.content:
|
|
events.append(
|
|
SimpleNamespace(
|
|
event_type="step.delta",
|
|
index=index,
|
|
delta=SimpleNamespace(type="text", text=content.text),
|
|
)
|
|
)
|
|
events.append(SimpleNamespace(event_type="step.stop", index=index, usage=None))
|
|
index += 1
|
|
events.append(
|
|
SimpleNamespace(
|
|
event_type="interaction.completed",
|
|
interaction=SimpleNamespace(
|
|
id=interaction.id, status=interaction.status, steps=None, usage=interaction.usage
|
|
),
|
|
)
|
|
)
|
|
return events
|
|
|
|
|
|
class FakeInteractions:
|
|
"""Replays scripted event streams and records every request."""
|
|
|
|
def __init__(self, turns, get_results=()):
|
|
self.turns = list(turns)
|
|
self.get_results = list(get_results)
|
|
self.requests: list[dict] = []
|
|
self.get_calls: list[str] = []
|
|
|
|
async def create(self, **request):
|
|
self.requests.append(request)
|
|
events = self.turns.pop(0)
|
|
if isinstance(events, Exception):
|
|
raise events
|
|
|
|
async def gen():
|
|
for event in events:
|
|
yield event
|
|
|
|
return gen()
|
|
|
|
async def get(self, interaction_id):
|
|
self.get_calls.append(interaction_id)
|
|
if not self.get_results:
|
|
raise AssertionError(f"unexpected interactions.get({interaction_id})")
|
|
return self.get_results.pop(0)
|
|
|
|
|
|
def _fake_client(turns, get_results=()):
|
|
client = MagicMock()
|
|
client.aio.interactions = FakeInteractions(turns, get_results)
|
|
client.aio.files.delete = AsyncMock()
|
|
return client
|
|
|
|
|
|
def _valid_event(start=1.0, end=2.0):
|
|
return {
|
|
"start_time": start,
|
|
"end_time": end,
|
|
"transcription": "Battery Saver toggled on",
|
|
"confidence_score": 0.9,
|
|
"verification_timestamp_secs": (start + end) / 2,
|
|
}
|
|
|
|
|
|
def _media(tmp_path):
|
|
media = _ChunkMedia()
|
|
media.path = tmp_path / "segment.mp4"
|
|
media.path.write_bytes(b"video")
|
|
media.compressed_path = media.path
|
|
media.actual_start = 0.0
|
|
media.prompt_with_context = "Watch the toggle."
|
|
return media
|
|
|
|
|
|
def _stub_analyzer(client):
|
|
analyzer = MagicMock()
|
|
analyzer.client = client
|
|
analyzer.model_name = "gemini-3.8-flash"
|
|
analyzer.sub_system_prompt = "system"
|
|
analyzer.agentic_call_timeout_seconds = 30.0
|
|
analyzer.ctx.data_engine = None
|
|
return analyzer
|
|
|
|
|
|
_UPLOADED = SimpleNamespace(name="files/abc", uri="https://files/abc", mime_type="video/mp4")
|
|
|
|
|
|
def _extraction(tmp_path):
|
|
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))
|
|
return raw_video, controller
|
|
|
|
|
|
# ------------------------------------------------------------------- tools
|
|
|
|
|
|
def test_submit_tool_is_flat_json_schema_with_required_timeline():
|
|
tool = build_interactions_submit_tool()
|
|
assert tool["type"] == "function"
|
|
assert tool["name"] == "submit_answer"
|
|
assert "function" not in tool
|
|
assert "timeline_events" in tool["parameters"]["required"]
|
|
assert tool["parameters"]["properties"]["timeline_events"]["type"] == "array"
|
|
|
|
|
|
# --------------------------------------------------------- mode resolution
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"model, video_cfg, expected_mode, expected_chunk",
|
|
[
|
|
("gemini-3.8-flash", {}, "agentic", 600.0),
|
|
("gemini-3.8-flash", {"processing": "static"}, "static", 60.0),
|
|
("gemini-3.8-flash", {"agentic_chunk_size_seconds": 240.0}, "agentic", 240.0),
|
|
("gemini-3.5-flash-lite", {}, "static", 60.0),
|
|
("gemini-3.5-flash-lite", {"processing": "agentic"}, "agentic", 600.0),
|
|
("gemini-2.5-flash", {"processing": "agentic"}, "static", 60.0),
|
|
],
|
|
)
|
|
async def test_engine_resolves_video_processing(model, video_cfg, expected_mode, expected_chunk):
|
|
with (
|
|
patch(
|
|
"artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY",
|
|
SecretStr("test-key"),
|
|
),
|
|
patch(
|
|
"artemis.agents.video_analyzer.video_analyzer.genai.Client",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"artemis.agents.video_analyzer.video_analyzer.cleanup_abandoned_gemini_files",
|
|
AsyncMock(),
|
|
),
|
|
):
|
|
analyzer = VideoAnalyzer(_context(model, **video_cfg))
|
|
|
|
assert analyzer.use_native_gemini is True
|
|
assert analyzer.video_processing == expected_mode
|
|
assert analyzer.chunk_size_seconds == expected_chunk
|
|
|
|
|
|
def test_universal_engine_never_uses_agentic_processing():
|
|
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
|
analyzer = VideoAnalyzer(_context("gemini-3.8-flash"))
|
|
assert analyzer.use_native_gemini is False
|
|
assert analyzer.video_processing == "static"
|
|
assert analyzer.chunk_size_seconds == 60.0
|
|
|
|
|
|
# ------------------------------------------------------------ conversation
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agentic_chunk_commits_answer_and_records_usage(tmp_path):
|
|
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
|
analyzer = VideoAnalyzer(_context())
|
|
interaction = _interaction(
|
|
"v1_first",
|
|
[
|
|
_function_call(
|
|
"call_1",
|
|
{
|
|
"summary": "Toggle enabled",
|
|
"analysis": "Switch turned on at 1.5s",
|
|
"timeline_events": [_valid_event()],
|
|
},
|
|
)
|
|
],
|
|
)
|
|
client = _fake_client([_events(interaction, argument_chunks=3)])
|
|
analyzer.client = client
|
|
analyzer.use_native_gemini = True
|
|
analyzer.video_processing = "agentic"
|
|
analyzer.chunk_size_seconds = analyzer.agentic_chunk_size_seconds
|
|
raw_video, controller = _extraction(tmp_path)
|
|
|
|
spans = []
|
|
|
|
class RecordingSpan:
|
|
def __init__(self, name, **_):
|
|
self.name = name
|
|
self.payload = {}
|
|
self.result = None
|
|
spans.append(self)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
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(return_value=_UPLOADED)),
|
|
patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist,
|
|
patch.object(chunk_agentic, "TraceSpan", RecordingSpan),
|
|
):
|
|
result = await analyzer._exec_single_chunk(0.0, 5.0, "verify toggle")
|
|
|
|
assert (
|
|
result == "[from 0.0s to 5.0s] Summary: Toggle enabled Analysis: Switch turned on at 1.5s"
|
|
)
|
|
assert analyzer.blackboard.missing_intervals(0.0, 5.0, "verify toggle") == []
|
|
persist.assert_awaited_once()
|
|
assert persist.await_args.args[1] == [_valid_event()]
|
|
|
|
request = client.aio.interactions.requests[0]
|
|
assert request["model"] == "gemini-3.8-flash"
|
|
assert request["stream"] is True
|
|
assert request["input"][0] == {
|
|
"type": "video",
|
|
"uri": "https://files/abc",
|
|
"mime_type": "video/mp4",
|
|
"processing": "agentic",
|
|
}
|
|
assert request["input"][1]["type"] == "text"
|
|
assert request["tools"][0]["name"] == "submit_answer"
|
|
assert "previous_interaction_id" not in request
|
|
assert client.aio.interactions.get_calls == []
|
|
client.aio.files.delete.assert_awaited_once_with(name="files/abc")
|
|
|
|
call_span = next(s for s in spans if s.name == "gemini_agentic_video_call")
|
|
assert call_span.payload["usage_metadata"]["tool_use_tokens"] == 9725
|
|
assert call_span.payload["processing_calls"] == 2
|
|
assert call_span.result == "Function call: submit_answer"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejected_answer_is_sent_back_as_stateful_function_result(tmp_path):
|
|
bad_event = dict(_valid_event(), confidence_score=1.7)
|
|
first = _interaction(
|
|
"v1_first",
|
|
[
|
|
_function_call(
|
|
"call_1", {"summary": "s1", "analysis": "a1", "timeline_events": [bad_event]}
|
|
)
|
|
],
|
|
)
|
|
second = _interaction(
|
|
"v1_second",
|
|
[
|
|
_function_call(
|
|
"call_2", {"summary": "s2", "analysis": "a2", "timeline_events": [_valid_event()]}
|
|
)
|
|
],
|
|
)
|
|
client = _fake_client([_events(first), _events(second, processing_calls=0)])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist:
|
|
summary, analysis = await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
assert (summary, analysis) == ("s2", "a2")
|
|
persist.assert_awaited_once()
|
|
follow_up = client.aio.interactions.requests[1]
|
|
assert follow_up["previous_interaction_id"] == "v1_first"
|
|
assert len(follow_up["input"]) == 1
|
|
result_step = follow_up["input"][0]
|
|
assert result_step["type"] == "function_result"
|
|
assert result_step["call_id"] == "call_1"
|
|
assert result_step["name"] == "submit_answer"
|
|
assert "confidence_score" in result_step["result"]["error"]
|
|
# system_instruction and tools are interaction-scoped: they are not
|
|
# inherited through previous_interaction_id and must be re-sent.
|
|
assert follow_up["system_instruction"] == analyzer.sub_system_prompt
|
|
assert follow_up["tools"][0]["name"] == "submit_answer"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrong_tool_is_rejected_then_accepted(tmp_path):
|
|
first = _interaction("v1_first", [_function_call("call_1", {}, name="ns:other_tool")])
|
|
second = _interaction(
|
|
"v1_second",
|
|
[_function_call("call_2", {"summary": "s2", "analysis": "a2", "timeline_events": []})],
|
|
)
|
|
client = _fake_client([_events(first, 0), _events(second, 0)])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()):
|
|
summary, analysis = await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
assert (summary, analysis) == ("s2", "a2")
|
|
follow_up = client.aio.interactions.requests[1]
|
|
assert follow_up["input"][0]["call_id"] == "call_1"
|
|
assert "submit_answer" in follow_up["input"][0]["result"]["error"]
|
|
assert follow_up["system_instruction"] == analyzer.sub_system_prompt
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_tool_call_gets_one_reminder_then_raises(tmp_path):
|
|
first = _interaction(
|
|
"v1_first", [_model_output("I looked but did not call the tool.")], "completed"
|
|
)
|
|
second = _interaction("v1_second", [_model_output("Still just text.")], "completed")
|
|
client = _fake_client([_events(first, 0), _events(second, 0)])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with pytest.raises(SubAgentAnswerExhausted) as raised:
|
|
await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
assert raised.value.reason == _NO_TOOL_CALL_ERROR
|
|
assert "never called submit_answer" in str(raised.value)
|
|
assert len(client.aio.interactions.requests) == 2
|
|
reminder = client.aio.interactions.requests[1]
|
|
assert reminder["previous_interaction_id"] == "v1_first"
|
|
assert reminder["input"][0]["type"] == "text"
|
|
assert "submit_answer" in reminder["input"][0]["text"]
|
|
assert reminder["system_instruction"] == analyzer.sub_system_prompt
|
|
assert client.aio.interactions.get_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrong_tool_on_every_turn_raises(tmp_path):
|
|
first = _interaction("v1_first", [_function_call("call_1", {}, name="ns:other_tool")])
|
|
second = _interaction("v1_second", [_function_call("call_2", {}, name="other_tool")])
|
|
client = _fake_client([_events(first, 0), _events(second, 0)])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with (
|
|
patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist,
|
|
pytest.raises(SubAgentAnswerExhausted) as raised,
|
|
):
|
|
await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
assert raised.value.reason == _WRONG_TOOL_ERROR
|
|
persist.assert_not_awaited()
|
|
assert len(client.aio.interactions.requests) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unparsed_call_on_requires_action_is_reread_by_id(tmp_path):
|
|
"""A stream that ends in requires_action without a parsable call is re-read via get()."""
|
|
events = [
|
|
SimpleNamespace(event_type="interaction.created", interaction=SimpleNamespace(id="v1_x")),
|
|
SimpleNamespace(
|
|
event_type="interaction.completed",
|
|
interaction=SimpleNamespace(
|
|
id="v1_x", status="requires_action", steps=None, usage=_USAGE
|
|
),
|
|
),
|
|
]
|
|
full = _interaction(
|
|
"v1_x",
|
|
[_function_call("call_9", {"summary": "s9", "analysis": "a9", "timeline_events": []})],
|
|
)
|
|
client = _fake_client([events], get_results=[full])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()):
|
|
summary, analysis = await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
assert (summary, analysis) == ("s9", "a9")
|
|
assert client.aio.interactions.get_calls == ["v1_x"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unparsable_arguments_drop_the_step_and_reread_by_id(tmp_path):
|
|
"""Malformed streamed arguments must not stand in as an empty answer."""
|
|
events = [
|
|
SimpleNamespace(event_type="interaction.created", interaction=SimpleNamespace(id="v1_x")),
|
|
SimpleNamespace(
|
|
event_type="step.start",
|
|
index=0,
|
|
step=SimpleNamespace(
|
|
type="function_call", id="call_7", name="submit_answer", arguments={}
|
|
),
|
|
),
|
|
SimpleNamespace(
|
|
event_type="step.delta",
|
|
index=0,
|
|
delta=SimpleNamespace(type="arguments_delta", arguments='{"summary": "trunc'),
|
|
),
|
|
SimpleNamespace(event_type="step.stop", index=0, usage=None),
|
|
SimpleNamespace(
|
|
event_type="interaction.completed",
|
|
interaction=SimpleNamespace(
|
|
id="v1_x", status="requires_action", steps=None, usage=_USAGE
|
|
),
|
|
),
|
|
]
|
|
full = _interaction(
|
|
"v1_x",
|
|
[_function_call("call_7", {"summary": "s7", "analysis": "a7", "timeline_events": []})],
|
|
)
|
|
client = _fake_client([events], get_results=[full])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist:
|
|
summary, analysis = await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
assert (summary, analysis) == ("s7", "a7")
|
|
assert client.aio.interactions.get_calls == ["v1_x"]
|
|
assert len(client.aio.interactions.requests) == 1
|
|
persist.assert_awaited_once()
|
|
|
|
|
|
def test_pending_step_with_invalid_arguments_json_is_dropped():
|
|
step = chunk_agentic._PendingStep(kind="function_call", call_id="c", name="submit_answer")
|
|
step.arguments_json.append("{not json")
|
|
assert step.finish() is None
|
|
|
|
intact = chunk_agentic._PendingStep(kind="function_call", call_id="c", name="submit_answer")
|
|
intact.arguments_json.extend(['{"summary": ', '"ok"}'])
|
|
assert intact.finish().arguments == {"summary": "ok"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_answer_without_summary_is_sent_back_not_committed(tmp_path):
|
|
first = _interaction(
|
|
"v1_first",
|
|
[_function_call("call_1", {"timeline_events": [_valid_event()]})],
|
|
)
|
|
second = _interaction(
|
|
"v1_second",
|
|
[
|
|
_function_call(
|
|
"call_2", {"summary": "s2", "analysis": "a2", "timeline_events": [_valid_event()]}
|
|
)
|
|
],
|
|
)
|
|
client = _fake_client([_events(first), _events(second, processing_calls=0)])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist:
|
|
summary, analysis = await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
assert (summary, analysis) == ("s2", "a2")
|
|
# Only the accepted answer reaches persistence.
|
|
persist.assert_awaited_once()
|
|
follow_up = client.aio.interactions.requests[1]
|
|
assert follow_up["previous_interaction_id"] == "v1_first"
|
|
result_step = follow_up["input"][0]
|
|
assert result_step["type"] == "function_result"
|
|
assert result_step["call_id"] == "call_1"
|
|
assert "summary" in result_step["result"]["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_answer_never_given_a_summary_raises_and_persists_nothing(tmp_path):
|
|
first = _interaction("v1_first", [_function_call("call_1", {"timeline_events": []})])
|
|
second = _interaction("v1_second", [_function_call("call_2", {"summary": " "})])
|
|
client = _fake_client([_events(first, 0), _events(second, 0)])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with (
|
|
patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist,
|
|
pytest.raises(SubAgentAnswerExhausted) as raised,
|
|
):
|
|
await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
assert raised.value.reason == _MISSING_SUMMARY_ERROR
|
|
persist.assert_not_awaited()
|
|
assert len(client.aio.interactions.requests) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_last_turn_timeline_validation_failure_raises_and_persists_nothing(tmp_path):
|
|
"""A summary whose timeline still fails validation on the last turn is never accepted."""
|
|
bad_event = dict(_valid_event(), confidence_score=1.7)
|
|
first = _interaction(
|
|
"v1_first",
|
|
[
|
|
_function_call(
|
|
"call_1", {"summary": "s1", "analysis": "a1", "timeline_events": [bad_event]}
|
|
)
|
|
],
|
|
)
|
|
out_of_range = dict(_valid_event(), verification_timestamp_secs=42.0)
|
|
second = _interaction(
|
|
"v1_second",
|
|
[
|
|
_function_call(
|
|
"call_2",
|
|
{"summary": "s2", "analysis": "a2", "timeline_events": [out_of_range]},
|
|
)
|
|
],
|
|
)
|
|
client = _fake_client([_events(first, 0), _events(second, 0)])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with (
|
|
patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist,
|
|
pytest.raises(SubAgentAnswerExhausted) as raised,
|
|
):
|
|
await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
# The message carries the last rejection the model received.
|
|
assert "verification_timestamp_secs" in raised.value.reason
|
|
assert "42.0" in str(raised.value)
|
|
persist.assert_not_awaited()
|
|
assert len(client.aio.interactions.requests) == 2
|
|
assert client.aio.interactions.requests[1]["input"][0]["call_id"] == "call_1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exhausted_agentic_chunk_is_not_committed_to_the_blackboard(tmp_path):
|
|
first = _interaction("v1_first", [_model_output("text only")], "completed")
|
|
second = _interaction("v1_second", [_model_output("still text")], "completed")
|
|
client = _fake_client([_events(first, 0), _events(second, 0)])
|
|
analyzer = _stub_analyzer(client)
|
|
analyzer.upload_and_poll_file = AsyncMock(return_value=_UPLOADED)
|
|
analyzer.cloud_files_to_cleanup = set()
|
|
|
|
with pytest.raises(SubAgentAnswerExhausted):
|
|
await run_agentic_chunk_conversation(
|
|
analyzer, _media(tmp_path), 0.0, 5.0, 0.0, 5.0, "verify toggle", "lease-1"
|
|
)
|
|
|
|
analyzer.blackboard.complete_segment.assert_not_called()
|
|
# The uploaded clip is still cleaned up on the way out.
|
|
client.aio.files.delete.assert_awaited_once_with(name="files/abc")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"reason",
|
|
[
|
|
_NO_TOOL_CALL_ERROR,
|
|
_WRONG_TOOL_ERROR,
|
|
_MISSING_SUMMARY_ERROR,
|
|
"Error: 'verification_timestamp_secs' 42.0 must fall within the start_time (0.0)"
|
|
" and end_time (5.0) boundaries. Fix the payload and call submit_answer again.",
|
|
"Error: 'confidence_score' is missing or out of bounds in one of the"
|
|
" timeline_events. It must be a float between 0.0 and 1.0 inclusive.",
|
|
],
|
|
)
|
|
def test_exhaustion_is_classified_as_retryable_unknown(reason):
|
|
"""No rejection text may accidentally match a transport / media marker."""
|
|
failure = classify_video_failure(SubAgentAnswerExhausted(2, reason))
|
|
assert failure.category is VideoFailureCategory.UNKNOWN
|
|
assert failure.retryable
|
|
assert failure.should_split
|
|
assert failure.should_fallback
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exhausted_chunk_leaves_the_interval_unanswered_for_re_analysis(tmp_path):
|
|
"""End to end: exhaustion fails the segment instead of caching an empty success."""
|
|
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
|
analyzer = VideoAnalyzer(_context())
|
|
|
|
def text_only_turns():
|
|
first = _interaction("v1_first", [_model_output("text only")], "completed")
|
|
second = _interaction("v1_second", [_model_output("still text")], "completed")
|
|
return [_events(first, 0), _events(second, 0)]
|
|
|
|
# UNKNOWN is retried once from scratch, so two full conversations are scripted.
|
|
analyzer.client = _fake_client(text_only_turns() + text_only_turns())
|
|
analyzer.use_native_gemini = True
|
|
analyzer.video_processing = "agentic"
|
|
raw_video, controller = _extraction(tmp_path)
|
|
|
|
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(return_value=_UPLOADED)),
|
|
patch.object(
|
|
analyzer,
|
|
"_exec_single_chunk_universal",
|
|
new=AsyncMock(side_effect=RuntimeError("fallback engine declined")),
|
|
) as universal,
|
|
patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event"),
|
|
patch("artemis.agents.video_analyzer.video_analyzer.asyncio.sleep", new=AsyncMock()),
|
|
pytest.raises(RuntimeError, match="fallback engine declined"),
|
|
):
|
|
await analyzer._exec_single_chunk(0.0, 5.0, "verify toggle")
|
|
|
|
assert len(analyzer.client.aio.interactions.requests) == 4
|
|
universal.assert_awaited_once()
|
|
# Nothing succeeded, so the next claim re-analyses instead of hitting a cache.
|
|
assert analyzer.blackboard.missing_intervals(0.0, 5.0, "verify toggle") == [(0.0, 5.0)]
|
|
assert analyzer.blackboard.claim_segment(0.0, 5.0, "verify toggle").state == "claimed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status", ["failed", "cancelled", "incomplete", "budget_exceeded"])
|
|
async def test_completed_with_terminal_failure_status_raises(tmp_path, status):
|
|
events = [
|
|
SimpleNamespace(event_type="interaction.created", interaction=SimpleNamespace(id="v1")),
|
|
SimpleNamespace(
|
|
event_type="interaction.completed",
|
|
interaction=SimpleNamespace(
|
|
id="v1",
|
|
status=status,
|
|
steps=None,
|
|
usage=None,
|
|
errors=[SimpleNamespace(code="internal", message="video worker died")],
|
|
),
|
|
),
|
|
]
|
|
client = _fake_client([events])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with pytest.raises(RuntimeError, match=f"{status}.*video worker died"):
|
|
await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
# No follow-up is chained onto the failed interaction.
|
|
assert len(client.aio.interactions.requests) == 1
|
|
assert client.aio.interactions.get_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completed_with_errors_raises_even_when_status_is_completed(tmp_path):
|
|
events = [
|
|
SimpleNamespace(event_type="interaction.created", interaction=SimpleNamespace(id="v1")),
|
|
SimpleNamespace(
|
|
event_type="interaction.completed",
|
|
interaction=SimpleNamespace(
|
|
id="v1",
|
|
status="completed",
|
|
steps=None,
|
|
usage=None,
|
|
errors=[SimpleNamespace(code="unavailable", message="overloaded")],
|
|
),
|
|
),
|
|
]
|
|
client = _fake_client([events])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with pytest.raises(RuntimeError, match="overloaded"):
|
|
await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_error_event_raises(tmp_path):
|
|
events = [
|
|
SimpleNamespace(event_type="interaction.created", interaction=SimpleNamespace(id="v1")),
|
|
SimpleNamespace(
|
|
event_type="error", error=SimpleNamespace(code="unavailable", message="overloaded")
|
|
),
|
|
]
|
|
client = _fake_client([events])
|
|
analyzer = _stub_analyzer(client)
|
|
|
|
with pytest.raises(RuntimeError, match="overloaded"):
|
|
await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_is_streamed_into_the_trace(tmp_path):
|
|
interaction = _interaction(
|
|
"v1_first",
|
|
[
|
|
_model_output("Toggle appears at 74s."),
|
|
_function_call("call_1", {"summary": "s", "analysis": "a", "timeline_events": []}),
|
|
],
|
|
)
|
|
client = _fake_client([_events(interaction, processing_calls=3)])
|
|
analyzer = _stub_analyzer(client)
|
|
lines = []
|
|
analyzer.ctx.data_engine = SimpleNamespace(
|
|
stream_output=lambda trace_id, text, is_thinking=None: lines.append((is_thinking, text)),
|
|
record_trace=lambda **kw: None,
|
|
)
|
|
|
|
with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()):
|
|
await _drive_agentic_loop(
|
|
analyzer, _UPLOADED, "trace-1", _media(tmp_path), 0.0, 5.0, "verify toggle"
|
|
)
|
|
|
|
thinking = [text for is_thinking, text in lines if is_thinking]
|
|
text = [text for is_thinking, text in lines if not is_thinking]
|
|
assert len(thinking) == 3
|
|
assert "video lookup 3" in thinking[-1]
|
|
assert text == ["Toggle appears at 74s."]
|
|
|
|
|
|
# ---------------------------------------------------------------- degrade
|
|
|
|
|
|
class _BadRequest(Exception):
|
|
status_code = 400
|
|
|
|
|
|
class _PayloadTooLarge(Exception):
|
|
status_code = 413
|
|
|
|
|
|
def test_agentic_rejection_is_only_a_400_about_processing():
|
|
rejected = _BadRequest("400 INVALID_ARGUMENT: processing 'agentic' is not supported")
|
|
assert is_agentic_rejection(classify_video_failure(rejected), rejected)
|
|
|
|
other_400 = _BadRequest("400 INVALID_ARGUMENT: unknown field 'tools[0].foo'")
|
|
assert not is_agentic_rejection(classify_video_failure(other_400), other_400)
|
|
|
|
too_large = _PayloadTooLarge("413 request entity too large: processing")
|
|
failure = classify_video_failure(too_large)
|
|
assert failure.category is VideoFailureCategory.BAD_REQUEST
|
|
assert failure.should_split
|
|
assert not is_agentic_rejection(failure, too_large)
|
|
|
|
outage = RuntimeError("503 service unavailable while processing")
|
|
assert not is_agentic_rejection(classify_video_failure(outage), outage)
|
|
|
|
|
|
def test_degraded_marker_replans_without_fallback():
|
|
failure = classify_video_failure(AgenticVideoDegraded("rejected"))
|
|
assert failure.should_split
|
|
assert not failure.should_fallback
|
|
assert not failure.retryable
|
|
|
|
|
|
def _agentic_analyzer(tmp_path, first_turn):
|
|
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
|
analyzer = VideoAnalyzer(_context())
|
|
analyzer.client = _fake_client([first_turn])
|
|
analyzer.use_native_gemini = True
|
|
analyzer.video_processing = "agentic"
|
|
analyzer.chunk_size_seconds = analyzer.agentic_chunk_size_seconds
|
|
raw_video, controller = _extraction(tmp_path)
|
|
return analyzer, raw_video, controller
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejected_agentic_request_degrades_run_and_hands_chunk_back(tmp_path):
|
|
analyzer, raw_video, controller = _agentic_analyzer(
|
|
tmp_path, _BadRequest("processing agentic is not supported")
|
|
)
|
|
|
|
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(return_value=_UPLOADED)),
|
|
patch(
|
|
"artemis.agents.video_analyzer.chunk_native._run_native_chunk_conversation",
|
|
new=AsyncMock(),
|
|
) as static_path,
|
|
patch.object(analyzer, "_exec_single_chunk_universal", new=AsyncMock()) as universal,
|
|
patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event") as record_event,
|
|
pytest.raises(AgenticVideoDegraded),
|
|
):
|
|
await analyzer._exec_single_chunk(0.0, 300.0, "verify toggle")
|
|
|
|
# The 300s clip is neither re-run statically nor handed to the fallback.
|
|
static_path.assert_not_awaited()
|
|
universal.assert_not_awaited()
|
|
assert analyzer.video_processing == "static"
|
|
assert analyzer.chunk_size_seconds == 60.0
|
|
assert record_event.call_args.args[0] == "llm_fallback"
|
|
assert record_event.call_args.args[1]["reason"] == "agentic_video_rejected"
|
|
# The lease is released so the re-planned pieces can be claimed.
|
|
assert analyzer.blackboard.missing_intervals(0.0, 300.0, "verify toggle") == [(0.0, 300.0)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_degraded_chunk_is_replanned_at_the_static_chunk_size(tmp_path):
|
|
analyzer, raw_video, controller = _agentic_analyzer(
|
|
tmp_path, _BadRequest("processing agentic is not supported")
|
|
)
|
|
|
|
async def static_answer(analyzer_, media, cs, ce, *rest):
|
|
return f"[from {cs:.1f}s to {ce:.1f}s] Summary: static Analysis: ok"
|
|
|
|
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(return_value=_UPLOADED)),
|
|
patch(
|
|
"artemis.agents.video_analyzer.chunk_native._run_native_chunk_conversation",
|
|
new=AsyncMock(side_effect=static_answer),
|
|
) as static_path,
|
|
patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event"),
|
|
):
|
|
result = await analyzer.exec_spawn_sub_agent(0.0, 150.0, "verify toggle")
|
|
|
|
# One agentic attempt on the 150s clip, then 60s static pieces.
|
|
assert len(analyzer.client.aio.interactions.requests) == 1
|
|
segments = [(call.args[2], call.args[3]) for call in static_path.await_args_list]
|
|
assert sorted(segments) == [(0.0, 60.0), (60.0, 120.0), (120.0, 150.0)]
|
|
assert all(ce - cs <= 60.0 for cs, ce in segments)
|
|
assert "PARTIAL" not in result
|
|
assert result.count("Summary: static") == 3
|
|
assert analyzer.video_processing == "static"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_413_in_agentic_mode_splits_without_degrading(tmp_path):
|
|
analyzer, raw_video, controller = _agentic_analyzer(
|
|
tmp_path, _PayloadTooLarge("413 request entity too large")
|
|
)
|
|
|
|
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(return_value=_UPLOADED)),
|
|
patch.object(
|
|
analyzer,
|
|
"_exec_single_chunk_universal",
|
|
new=AsyncMock(side_effect=_PayloadTooLarge("413 still too large")),
|
|
),
|
|
patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event") as record_event,
|
|
pytest.raises(_PayloadTooLarge) as raised,
|
|
):
|
|
await analyzer._exec_single_chunk(0.0, 300.0, "verify toggle")
|
|
|
|
assert classify_video_failure(raised.value).should_split
|
|
assert analyzer.video_processing == "agentic"
|
|
assert analyzer.chunk_size_seconds == analyzer.agentic_chunk_size_seconds
|
|
reasons = [call.args[1].get("reason") for call in record_event.call_args_list]
|
|
assert "agentic_video_rejected" not in reasons
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unrelated_400_in_agentic_mode_does_not_degrade(tmp_path):
|
|
analyzer, raw_video, controller = _agentic_analyzer(
|
|
tmp_path, _BadRequest("400 INVALID_ARGUMENT: unknown field 'tools[0].foo'")
|
|
)
|
|
|
|
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(return_value=_UPLOADED)),
|
|
patch.object(
|
|
analyzer,
|
|
"_exec_single_chunk_universal",
|
|
new=AsyncMock(side_effect=_BadRequest("400 unknown field")),
|
|
),
|
|
patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event") as record_event,
|
|
pytest.raises(_BadRequest),
|
|
):
|
|
await analyzer._exec_single_chunk(0.0, 300.0, "verify toggle")
|
|
|
|
assert analyzer.video_processing == "agentic"
|
|
reasons = [call.args[1].get("reason") for call in record_event.call_args_list]
|
|
assert "agentic_video_rejected" not in reasons
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transient_agentic_failure_flows_through_shared_retry(tmp_path):
|
|
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
|
analyzer = VideoAnalyzer(_context())
|
|
interaction = _interaction(
|
|
"v1_retry",
|
|
[_function_call("call_1", {"summary": "s", "analysis": "a", "timeline_events": []})],
|
|
)
|
|
analyzer.client = _fake_client(
|
|
[RuntimeError("503 provider unavailable"), _events(interaction, 1)]
|
|
)
|
|
analyzer.use_native_gemini = True
|
|
analyzer.video_processing = "agentic"
|
|
raw_video, controller = _extraction(tmp_path)
|
|
|
|
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(return_value=_UPLOADED)),
|
|
patch(
|
|
"artemis.agents.video_analyzer.video_analyzer.asyncio.sleep",
|
|
new=AsyncMock(),
|
|
),
|
|
):
|
|
result = await analyzer._exec_single_chunk(0.0, 5.0, "verify toggle")
|
|
|
|
assert result == "[from 0.0s to 5.0s] Summary: s Analysis: a"
|
|
assert analyzer.video_processing == "agentic"
|
|
assert len(analyzer.client.aio.interactions.requests) == 2
|