Files
Artemis-Eapil/tests/unit/memory/test_transcript_ledger.py
T
somew1nd a45daf23f8 feat: add agentic video analysis and clarify action results
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.
2026-09-08 17:05:54 -07:00

421 lines
16 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.
"""Unit tests for the Pro session transcript ledger (history redesign §3.2, M2).
Covers the four-region discipline: S-region byte stability across turns, the
append-only active region, depth-1 stripping of old UI lists and plan
recitations, the depth-K screenshot scrub in the Pro message shape (grace /
placeholder / freeze — the M1 race regressions re-run against HumanMessage
observations keyed by step id), ``T+mm:ss`` session-offset timestamps, the
tool-call/response pairing invariant, and the cold-start restored-history
block.
"""
import json
import re
import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from artemis.memory.step_memory import StepMemoryService
from artemis.memory.transcript import (
EXECUTION_RESULT_MARKER,
PLAN_RECITATION_MARKER,
PRO_UI_LIST_MARKER,
RESTORED_HISTORY_HEADER,
TranscriptLedger,
format_session_offset,
)
OFFSET_RE = re.compile(r"T\+\d{2,}:\d{2}")
def _service() -> StepMemoryService:
return StepMemoryService(ctx=None)
def _observation(i: int) -> HumanMessage:
return HumanMessage(
content=[
{"type": "text", "text": f"# CURRENT OBSERVATION [T+00:0{i % 10}]"},
{"type": "text", "text": f"{PLAN_RECITATION_MARKER}\n- [/] milestone {i}"},
{"type": "text", "text": "--- Current Screenshot ---"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}},
{"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n[1] button {i}"},
]
)
def _turn(i: int, tool_call_count: int = 1) -> list:
messages: list = [_observation(i)]
if tool_call_count:
tool_calls = [
{"name": "click", "args": {"target": n + 1}, "id": f"tc{i}-{n}", "type": "tool_call"}
for n in range(tool_call_count)
]
messages.append(AIMessage(content=f"thinking {i}", tool_calls=tool_calls))
for tc in tool_calls:
messages.append(ToolMessage(tool_call_id=tc["id"], content="Action Recorded"))
return messages
def _play_turn(ledger: TranscriptLedger, i: int, *, prev_key=None, prev_result=None, **turn_kwargs):
"""Mimic the operator's real ordering: commit previous, render, stage."""
ledger.commit_staged(step_key=prev_key, validator_result=prev_result)
turn = _turn(i, **turn_kwargs)
rendered = ledger.render([turn[0]])
ledger.stage_turn(turn)
return rendered
def _fingerprint(msg) -> str:
return json.dumps(msg.content, sort_keys=True, default=str)
def _has_image(msg) -> bool:
return any(isinstance(b, dict) and b.get("type") in ("image_url", "image") for b in msg.content)
def test_format_session_offset():
assert format_session_offset(0) == "T+00:00"
assert format_session_offset(83) == "T+01:23"
# Minutes never wrap into hours: byte-stable monotonic labels.
assert format_session_offset(3725) == "T+62:05"
assert format_session_offset(-5) == "T+00:00"
def test_static_prefix_set_once_and_byte_stable():
ledger = TranscriptLedger(step_memory=_service())
system = SystemMessage(content="STATIC SYSTEM PROMPT")
ledger.set_static_prefix([system])
with pytest.raises(RuntimeError):
ledger.set_static_prefix([SystemMessage(content="other")])
fingerprints = set()
for i in range(1, 6):
rendered = _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
assert rendered[0] is system
fingerprints.add(rendered[0].content)
assert fingerprints == {"STATIC SYSTEM PROMPT"}
def test_active_region_is_append_only():
ledger = TranscriptLedger(step_memory=_service())
seen_ids: list[int] = []
for i in range(1, 5):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
active = ledger.active_messages
# The previously observed prefix is unchanged in identity and order.
assert [id(m) for m in active[: len(seen_ids)]] == seen_ids
seen_ids = [id(m) for m in active]
assert ledger.turn_count == 3 # 3 committed, 1 still staged
def test_validator_result_message_carries_session_offset():
ledger = TranscriptLedger(step_memory=_service())
_play_turn(ledger, 1)
_play_turn(
ledger,
2,
prev_key="step-1",
prev_result={"status": "dispatched", "execution": [{"action": "tap", "attempts": ["ok"]}]},
)
result_messages = [
m
for m in ledger.active_messages
if isinstance(m, HumanMessage) and str(m.content).find(EXECUTION_RESULT_MARKER) >= 0
]
assert len(result_messages) == 1
text = result_messages[0].content[0]["text"]
assert OFFSET_RE.search(text), text
assert "Status: dispatched" in text
assert "ago" not in text
def test_validator_result_skipped_for_actionless_turn():
ledger = TranscriptLedger(step_memory=_service())
_play_turn(ledger, 1)
_play_turn(ledger, 2, prev_key="step-1", prev_result=None)
assert not any(EXECUTION_RESULT_MARKER in str(m.content) for m in ledger.active_messages)
def test_old_turn_recitation_and_ui_list_stripped_at_depth_1():
ledger = TranscriptLedger(step_memory=_service())
_play_turn(ledger, 1)
rendered = _play_turn(ledger, 2, prev_key="step-1")
committed_obs = ledger.active_messages[0]
committed_text = str(committed_obs.content)
# Depth-1 scrub: the previous turn's plan recitation and UI list are gone.
assert PLAN_RECITATION_MARKER not in committed_text
assert PRO_UI_LIST_MARKER not in committed_text
# Its screenshot is still inside the K-depth active window.
assert _has_image(committed_obs)
# The live tail keeps both.
tail_text = str(rendered[-1].content)
assert PLAN_RECITATION_MARKER in tail_text
assert PRO_UI_LIST_MARKER in tail_text
def test_depth_k_image_resolved_to_ready_visual_summary():
service = _service()
service._step_inputs["step-1"] = {"step_number": 1}
service._summaries["step-1"] = "Objective visual transition 1."
ledger = TranscriptLedger(step_memory=service, image_scrub_depth=3)
for i in range(1, 5):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
first_obs = ledger.active_messages[0]
assert not _has_image(first_obs)
assert {
"type": "text",
"text": "--- Historical Visual Transition ---\nObjective visual transition 1.",
} in first_obs.content
def test_pending_grace_then_placeholder_never_backfilled():
service = _service()
service._step_inputs["step-1"] = {"step_number": 1} # pending job, no summary
ledger = TranscriptLedger(step_memory=service, image_scrub_depth=2, pending_grace_steps=1)
# Enough turns to push turn 1 past K + grace.
for i in range(1, 7):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
first_obs = ledger.active_messages[0]
assert not _has_image(first_obs)
assert "[visual summary pending; evidence at DataEngine step 1]" in str(first_obs.content)
frozen = _fingerprint(first_obs)
# A late summary must never mutate the frozen message.
service._summaries["step-1"] = "Too late."
_play_turn(ledger, 7, prev_key="step-6")
assert _fingerprint(ledger.active_messages[0]) == frozen
def test_failed_summary_becomes_unavailable_placeholder():
service = _service()
service._step_inputs["step-1"] = {"step_number": 1}
service._failed.add("step-1")
ledger = TranscriptLedger(step_memory=service, image_scrub_depth=2, pending_grace_steps=5)
for i in range(1, 4):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
first_obs = ledger.active_messages[0]
assert not _has_image(first_obs)
assert "[visual summary unavailable; evidence at DataEngine step 1]" in str(first_obs.content)
def test_tool_call_response_pairs_are_never_split():
ledger = TranscriptLedger(step_memory=_service(), image_scrub_depth=2)
for i in range(1, 7):
_play_turn(
ledger,
i,
prev_key=f"step-{i - 1}" if i > 1 else None,
prev_result={"status": "dispatched"} if i > 1 else None,
tool_call_count=2,
)
active = list(ledger.active_messages)
for idx, msg in enumerate(active):
if isinstance(msg, AIMessage) and msg.tool_calls:
following = active[idx + 1 : idx + 1 + len(msg.tool_calls)]
assert [getattr(m, "tool_call_id", None) for m in following] == [
tc["id"] for tc in msg.tool_calls
], f"tool-call pairing split at active index {idx}"
def test_restored_history_only_seeds_an_empty_ledger():
ledger = TranscriptLedger(step_memory=_service())
ledger.set_restored_history(f"{RESTORED_HISTORY_HEADER} steps 1-9 ...")
assert ledger.has_restored_history
with pytest.raises(RuntimeError):
ledger.set_restored_history("again")
ledger.set_static_prefix([SystemMessage(content="S")])
rendered = _play_turn(ledger, 1)
# Order: S region, then the frozen restored block, then the live tail.
assert rendered[0].content == "S"
assert RESTORED_HISTORY_HEADER in str(rendered[1].content)
assert "# CURRENT OBSERVATION" in str(rendered[-1].content)
ledger2 = TranscriptLedger(step_memory=_service())
_play_turn(ledger2, 1)
ledger2.commit_staged(step_key="step-1")
with pytest.raises(RuntimeError):
ledger2.set_restored_history("late")
def test_stage_twice_commits_the_forgotten_turn():
ledger = TranscriptLedger(step_memory=_service())
ledger.stage_turn(_turn(1))
ledger.stage_turn(_turn(2))
assert ledger.turn_count == 1
assert ledger.has_staged_turn
def test_no_ago_wording_in_ledger_output():
ledger = TranscriptLedger(step_memory=_service())
for i in range(1, 4):
_play_turn(
ledger,
i,
prev_key=f"step-{i - 1}" if i > 1 else None,
prev_result={"status": "dispatched"} if i > 1 else None,
)
blob = " ".join(str(m.content) for m in ledger.active_messages)
assert " ago" not in blob
# ---------------------------------------------------------------------------
# Session-start anchoring and multi-step turns (Flash profile reuse)
# ---------------------------------------------------------------------------
def test_session_start_anchors_offsets_to_the_data_engine_clock():
"""With a ``session_start`` epoch the ledger reads the wall clock, so the
``T+mm:ss`` labels are relative to the recorded session start (the same
origin as the chunk ledger lines and the video analyzer's timeline)."""
now = {"t": 1000.0}
ledger = TranscriptLedger(step_memory=_service(), clock=lambda: now["t"], session_start=940.0)
assert ledger.elapsed_label() == "T+01:00"
now["t"] = 1075.5
assert ledger.elapsed_label() == "T+02:15"
def test_without_session_start_the_ledger_keeps_its_own_origin():
now = {"t": 500.0}
ledger = TranscriptLedger(step_memory=_service(), clock=lambda: now["t"])
assert ledger.elapsed_label() == "T+00:00"
now["t"] = 512.0
assert ledger.elapsed_label() == "T+00:12"
def test_commit_records_every_step_key_of_a_multi_action_turn():
ledger = TranscriptLedger(step_memory=_service())
ledger.stage_turn(_turn(1, tool_call_count=2))
ledger.commit_staged(step_key="s1", extra_step_keys=["s2", "s1", "s3"])
turn = ledger.unchunked_turns()[0]
assert turn["step_key"] == "s1"
assert turn["step_keys"] == ["s1", "s2", "s3"]
ledger.stage_turn(_turn(2))
ledger.commit_staged(step_key=None)
assert ledger.unchunked_turns()[1]["step_keys"] == []
# ---------------------------------------------------------------------------
# Turn transcript rendering (chunk-capsule source)
# ---------------------------------------------------------------------------
def test_render_turn_transcript_is_lossless_over_text_thinking_tools_and_results():
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from artemis.memory.transcript import SCREENSHOT_PLACEHOLDER, render_turn_transcript
messages = [
HumanMessage(
content=[
{"type": "text", "text": "# CURRENT OBSERVATION [T+01:00]"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG"}},
]
),
AIMessage(
content=[
{"type": "thinking", "thinking": "compare the two prices"},
{"type": "text", "text": "I will recall the earlier price."},
],
tool_calls=[
{
"name": "search_history",
"args": {"query": "price"},
"id": "c1",
"type": "tool_call",
}
],
),
ToolMessage(tool_call_id="c1", content="Step 3: price ¥39", status="success"),
ToolMessage(
tool_call_id="c2", name="adb_shell", content="permission denied", status="error"
),
ToolMessage(tool_call_id="c9", content="orphan result"),
SystemMessage(content="You have reached the maximum number of tool calls"),
AIMessage(
content="tap now",
tool_calls=[{"name": "click", "args": {"target": [0.5, 0.5]}, "id": "c3"}],
),
]
text = render_turn_transcript(messages)
assert text.splitlines()[0] == "[observation]"
assert "# CURRENT OBSERVATION [T+01:00]" in text
assert SCREENSHOT_PLACEHOLDER in text and "IMG" not in text
assert "[operator]\n(thinking) compare the two prices\nI will recall the earlier price." in text
assert '[tool call] search_history({"query": "price"})' in text
# A Pro ToolMessage carries no name: resolved through the turn's own call.
assert "[tool result search_history]\nStep 3: price ¥39" in text
assert "[tool result adb_shell (error)]\npermission denied" in text
assert "[tool result c9]\norphan result" in text # unresolvable: id shown
assert "[system]\nYou have reached the maximum number of tool calls" in text
assert '[tool call] click({"target": [0.5, 0.5]})' in text
# Order is message order.
assert text.index("search_history(") < text.index("price ¥39") < text.index("tap now")
def test_ledger_turn_transcript_reflects_the_scrubbed_active_region():
"""The transcript handed to chunk compression is the turn as it stands
after the scrub edge: the UI list stripped, the validator result present."""
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger
ledger = TranscriptLedger(clock=lambda: 0.0)
def turn(i: int) -> list:
return [
HumanMessage(
content=[
{"type": "text", "text": f"# CURRENT OBSERVATION [T+00:0{i}]"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG{i}"}},
{"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n[0] Button {i}"},
]
),
AIMessage(
content=f"thought {i}",
tool_calls=[{"name": "click", "args": {}, "id": f"t{i}"}],
),
ToolMessage(tool_call_id=f"t{i}", content="Action Recorded"),
]
ledger.stage_turn(turn(1))
ledger.commit_staged(step_key="s1", validator_result={"status": "dispatched"})
ledger.stage_turn(turn(2))
ledger.commit_staged(step_key="s2", validator_result={"status": "failed", "error": "boom"})
ledger.render([HumanMessage(content=[{"type": "text", "text": "tail"}])])
first, second = ledger.unchunked_turns()
text = ledger.turn_transcript(first)
assert "thought 1" in text and "[tool call] click({})" in text
assert "Button 1" not in text # depth-1 strip already applied
assert "--- Action Execution Result (T+00:00) ---\nStatus: dispatched" in text
assert "Status: failed" in ledger.turn_transcript(second)
assert "thought 2" not in text # a turn transcript never leaks into another turn