Feature/video artifact verification (#537)

* fix(release): CI wait polls the prod rung; escape the header tooltip apostrophe

get_latest_ci_conclusion defaults to the ladder's head rung, so
wait_for_ci searched slave for a release commit that lives on master
and timed out after 40 minutes with the run already green. The wait
now passes the prod branch explicitly. Also fixes the
react/no-unescaped-entities error that turned master's Panel CI red.

* fix(panel,video): dead dialog triggers behind tooltips; dotted composition ids render

HelpTip nested inside a Dialog/AlertDialog trigger puts the trigger's
click handler on the Tooltip root, which renders no DOM — the agents
Spawn item and the KB Reindex-All / Delete-index confirms were dead.
Tooltips now wrap the triggers. The video renderer accepts interior
single dots in composition ids (release-0.25.0) with '..' still
unrepresentable, and propose_video refuses an unrenderable id at
authoring time.

* fix(dispatch): restart-safe PM review turns

A leaf task in awaiting_pm_review had no periodic pickup: the closure
dispatcher bailed on childless tasks and skipped PR-bearing review
tasks as already-promoted, assuming the submit-time PM session was
still alive — an assumption every restart breaks. Proven live on the
docs-sync leaf after the 0.25.0 redeploy, which also dependency-blocked
its sibling dev task. Childless awaiting_pm_review tasks now flow to
the PM's review turn, and the merge turn respawns its PM when none is
active.

* feat(video): verify the rendered artifact, not the source

The 14s release-0.25.0 cut shipped with only one of four scenes visibly
registering: the dev authored DOM, the smoke asserted DOM, QA read code —
nobody consumed the rendered MP4 before the CEO did. Close that loop, and
the reject loop behind it:

- sidecar frames mode: POST /render with frames=1..32 renders the cut,
  ffprobes the REAL duration, extracts midpoint-sampled keyframe PNGs
  (timestamps in filenames), streams a tar.gz back with X-Video-Duration
- request_render do-verb (developer/QA, request_sandbox's shape): renders
  the caller's ACTUAL composition — dev's own worktree (head_sha/dirty
  provenance), QA a read-only git-archive export of the assembled branch —
  extracts frames to the container-shared .previews/ path, stamps the
  render_preview marker, returns the paths as envelope evidence
- gate: i_am_done on a source=video task refuses without a stamped
  render_preview (Requirement.RENDER_VERIFIED; canonical source string
  moved to foundation as markers.VIDEO_TASK_SOURCE; mirrored in the
  possibilities-matrix fast path so it cannot bypass the check)
- QA claim_review evidence carries video_context (composition id, the
  dev's preview, a re-render instruction) so review checks output
- dev spawn prompt block + a 4th authoring AC order Read-every-frame
  verification before submitting
- reject -> re-author: a CEO reject with a reason opens a fresh authoring
  task carrying the verbatim feedback + a revise-in-place pointer at the
  existing composition (best-effort, never fails the reject) — rejection
  feedback no longer dies on the cancelled draft

E2E: rendered the committed release-0.25.0 composition through the new
frames mode locally — the returned keyframes show exactly the reported
failure (blank frame at 5.8s, only 'Env ladder' by 12.8s), the check the
fleet was missing.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-16 19:49:26 +02:00
committed by GitHub
co-authored by Renn F
parent 797847e379
commit aa15dc40cc
37 changed files with 2146 additions and 54 deletions
@@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.foundation.policy.content import markers
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.envelope import Envelope
@@ -61,6 +62,8 @@ def _t(
t.pr_number = 12345 if pr_created else None
t.acceptance_criteria = list(criteria)
t.acceptance_criteria_status = ac_status
t.source = "code"
t.orchestration_markers = None
return t
@@ -334,6 +337,44 @@ async def test_fast_path_open_findings_blocks(monkeypatch: pytest.MonkeyPatch) -
c.task.submit_qa.assert_not_awaited()
@pytest.mark.asyncio
async def test_fast_path_video_task_without_render_preview_rejects(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A video-authoring task with no request_render preview must not be
able to fast-path around looking at the rendered artifact."""
c = Choreographer(_deps())
stubs = _stub_fast_path(c, monkeypatch)
tracing_gap = AsyncMock(
return_value=Envelope.tracing_gap(missing=["render_preview"], remediate="x")
)
monkeypatch.setattr(c, "_build_tracing_gap", tracing_gap)
ctx = _ctx()
ctx.task = _t()
ctx.task.source = markers.VIDEO_TASK_SOURCE
await c._i_am_done_fast_path(ctx)
stubs.reject.assert_awaited_once()
c.task.submit_qa.assert_not_awaited()
tracing_gap.assert_awaited_once_with(
ctx.agent_id, ctx.task_id, ["render_preview"], task=ctx.task
)
@pytest.mark.asyncio
async def test_fast_path_video_task_with_render_preview_passes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
c = Choreographer(_deps())
stubs = _stub_fast_path(c, monkeypatch)
ctx = _ctx()
ctx.task = _t()
ctx.task.source = markers.VIDEO_TASK_SOURCE
ctx.task.orchestration_markers = {markers.RENDER_PREVIEW: {"frames": ["a.png"]}}
await c._i_am_done_fast_path(ctx)
stubs.ok.assert_awaited_once()
c.task.submit_qa.assert_awaited_once()
@pytest.mark.asyncio
async def test_fast_path_conventions_block_rejects(
monkeypatch: pytest.MonkeyPatch,
@@ -0,0 +1,485 @@
"""ContentActions.request_render — render a video composition to preview
frames so an agent verifies the RENDERED artifact, not just its source.
Guard matrix (flag off / no active task / non-video source / renderer
unconfigured / missing composition_id / bad composition_id-orientation-
frame_count / missing composition dir on disk / role gate), the dev and QA
success paths (frame extraction + marker payload shape), and a
VideoRendererError surfacing as a retryable rejection.
"""
from __future__ import annotations
import io
import tarfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.foundation.policy.content import markers
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
from roboco.services.video_renderer_client import VideoRendererError
def _make_actions(
*,
task_obj: MagicMock | None,
role: str = "developer",
team: str | None = "ux_ui",
workspace: MagicMock | None = None,
) -> tuple[ContentActions, MagicMock]:
task = AsyncMock()
task.get_active_task_for_agent.return_value = task_obj
task.session = MagicMock()
task.session.flush = AsyncMock()
task.heartbeat = AsyncMock()
agent = MagicMock()
agent.role = role
agent.team = team
task.agent_for = AsyncMock(return_value=agent)
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=workspace or MagicMock(),
notifications=MagicMock(),
)
return ContentActions(deps), task
def _task(
*,
project_id: object | None = uuid4(),
source: str = markers.VIDEO_TASK_SOURCE,
branch_name: str | None = "feature/ux_ui/ABCD1234",
draft: dict[str, object] | None = None,
) -> MagicMock:
t = MagicMock()
t.id = uuid4()
t.project_id = project_id
t.status = "in_progress"
t.source = source
t.branch_name = branch_name
t.orchestration_markers = {"video_draft": draft} if draft else None
return t
def _stub_project(
monkeypatch: pytest.MonkeyPatch, *, slug: str = "demo-project"
) -> MagicMock:
project = MagicMock(slug=slug, git_url="https://example.invalid/demo.git")
project_service = MagicMock()
project_service.get = AsyncMock(return_value=project)
monkeypatch.setattr(
"roboco.services.project.get_project_service", lambda _s: project_service
)
return project
def _stub_renderer(
monkeypatch: pytest.MonkeyPatch,
*,
frames_tar_gz: bytes = b"",
duration: float = 2.5,
error: Exception | None = None,
) -> MagicMock:
renderer = MagicMock()
if error is not None:
renderer.render_frames = AsyncMock(side_effect=error)
else:
renderer.render_frames = AsyncMock(return_value=(frames_tar_gz, duration))
monkeypatch.setattr(
"roboco.services.video_renderer_client.get_video_renderer",
lambda: renderer,
)
return renderer
def _make_frames_tar(names: list[str]) -> bytes:
"""An in-memory tar.gz of a few frame files, matching what render_frames
returns on success — the render loop extracts this straight to disk."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for name in names:
data = f"fake-png-bytes-{name}".encode()
info = tarfile.TarInfo(name=name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return buf.getvalue()
def _arm(
monkeypatch: pytest.MonkeyPatch, *, renderer_url: str = "http://sidecar:3001"
) -> None:
monkeypatch.setattr(settings, "video_engine_enabled", True)
monkeypatch.setattr(settings, "video_renderer_base_url", renderer_url)
# --------------------------------------------------------------------------- #
# guard matrix
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_flag_off_refuses_before_task_lookup(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "video_engine_enabled", False)
actions, task_svc = _make_actions(task_obj=None)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
task_svc.get_active_task_for_agent.assert_not_awaited()
@pytest.mark.asyncio
async def test_no_active_task_refused(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "video_engine_enabled", True)
actions, _task_svc = _make_actions(task_obj=None)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_non_video_source_refused(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "video_engine_enabled", True)
t = _task(source="chore")
actions, _task_svc = _make_actions(task_obj=t)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
assert "video-authoring" in (env.message or "") + (env.remediate or "")
@pytest.mark.asyncio
async def test_renderer_unconfigured_refused(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "video_engine_enabled", True)
monkeypatch.setattr(settings, "video_renderer_base_url", "")
t = _task()
actions, _task_svc = _make_actions(task_obj=t)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
assert "ROBOCO_VIDEO_RENDERER_BASE_URL" in (env.remediate or "")
@pytest.mark.asyncio
async def test_missing_composition_id_is_incomplete_input(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_arm(monkeypatch)
t = _task(draft=None)
actions, _task_svc = _make_actions(task_obj=t)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "incomplete_input"
assert "composition_id" in (env.missing or [])
@pytest.mark.asyncio
async def test_bad_composition_id_regex_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_arm(monkeypatch)
t = _task()
actions, _task_svc = _make_actions(task_obj=t)
env = await actions.request_render(agent_id=uuid4(), composition_id="bad id!")
assert env.error == "invalid_state"
assert "not renderable" in (env.message or "")
@pytest.mark.asyncio
async def test_bad_orientation_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
_arm(monkeypatch)
t = _task()
actions, _task_svc = _make_actions(task_obj=t)
env = await actions.request_render(
agent_id=uuid4(), composition_id="release-v1", orientation="landscape"
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_frame_count_out_of_range_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_arm(monkeypatch)
t = _task()
actions, _task_svc = _make_actions(task_obj=t)
too_low = await actions.request_render(
agent_id=uuid4(), composition_id="release-v1", frame_count=0
)
too_high = await actions.request_render(
agent_id=uuid4(), composition_id="release-v1", frame_count=33
)
assert too_low.error == "invalid_state"
assert too_high.error == "invalid_state"
@pytest.mark.asyncio
async def test_missing_composition_dir_on_disk_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch)
clone_root = tmp_path / "clone"
clone_root.mkdir(parents=True) # no motion/ tree at all
workspace = MagicMock()
workspace.get_clone_root_path.return_value = clone_root
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "x"
t = _task(draft={"composition_id": "release-v1"})
actions, _task_svc = _make_actions(task_obj=t, workspace=workspace)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
assert "motion/compositions/release-v1" in (env.message or "")
@pytest.mark.asyncio
async def test_other_role_not_authorized(monkeypatch: pytest.MonkeyPatch) -> None:
_arm(monkeypatch)
_stub_project(monkeypatch)
t = _task(draft={"composition_id": "release-v1"})
actions, _task_svc = _make_actions(task_obj=t, role="documenter", team="ux_ui")
env = await actions.request_render(agent_id=uuid4())
assert env.error == "not_authorized"
# --------------------------------------------------------------------------- #
# dev happy path — own working tree
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_dev_happy_path_extracts_frames_and_stamps_marker(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch, slug="demo-project")
clone_root = tmp_path / "clone"
(clone_root / "motion" / "compositions" / "release-v1").mkdir(parents=True)
workspace = MagicMock()
workspace.get_clone_root_path.return_value = clone_root
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "deadbeef"
t = _task(draft={"composition_id": "release-v1"})
actions, task_svc = _make_actions(
task_obj=t, role="developer", team="ux_ui", workspace=workspace
)
expected_frame_count = 2
expected_duration = 3.2
default_frame_count = 8
tar_bytes = _make_frames_tar(["frame-0.png", "frame-1.png"])
_stub_renderer(monkeypatch, frames_tar_gz=tar_bytes, duration=expected_duration)
env = await actions.request_render(agent_id=uuid4())
assert env.error is None
assert env.status == t.status
assert env.task_id == str(t.id)
assert env.evidence is not None
frames = env.evidence["frames"]
assert len(frames) == expected_frame_count
for p in frames:
assert Path(p).is_file()
assert env.evidence["duration_seconds"] == expected_duration
assert env.evidence["source"] == "workspace"
assert env.evidence["dirty"] is False
assert env.evidence["rendered_by"]
assert "note" in env.evidence
assert "frames[]" in (env.next or "")
payload = markers.get_render_preview(t)
assert payload is not None
assert payload["composition_id"] == "release-v1"
assert payload["orientation"] == "vertical"
assert payload["frame_count"] == default_frame_count
assert payload["duration_seconds"] == expected_duration
assert payload["source"] == "workspace"
assert payload["dirty"] is False
assert payload["frames"] == frames
assert "at" in payload
task_svc.session.flush.assert_awaited()
task_svc.heartbeat.assert_awaited_once()
@pytest.mark.asyncio
async def test_explicit_composition_id_backfills_video_draft(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A dev who never called propose_video must still leave
video_draft.composition_id stamped — the post-completion render loop
keys on it and skips silently when absent (proven live)."""
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch, slug="demo-project")
clone_root = tmp_path / "clone"
(clone_root / "motion" / "compositions" / "release-v1").mkdir(parents=True)
workspace = MagicMock()
workspace.get_clone_root_path.return_value = clone_root
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "deadbeef"
t = _task(draft=None)
actions, _ = _make_actions(
task_obj=t, role="developer", team="ux_ui", workspace=workspace
)
_stub_renderer(
monkeypatch, frames_tar_gz=_make_frames_tar(["frame-0.png"]), duration=1.0
)
env = await actions.request_render(agent_id=uuid4(), composition_id="release-v1")
assert env.error is None
draft = markers.get_video_draft(t)
assert draft is not None
assert draft["composition_id"] == "release-v1"
@pytest.mark.asyncio
async def test_existing_video_draft_composition_id_not_overwritten(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch, slug="demo-project")
clone_root = tmp_path / "clone"
for comp in ("release-v1", "release-v2"):
(clone_root / "motion" / "compositions" / comp).mkdir(parents=True)
workspace = MagicMock()
workspace.get_clone_root_path.return_value = clone_root
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "deadbeef"
t = _task(draft={"composition_id": "release-v1", "occasion": "r1"})
actions, _ = _make_actions(
task_obj=t, role="developer", team="ux_ui", workspace=workspace
)
_stub_renderer(
monkeypatch, frames_tar_gz=_make_frames_tar(["frame-0.png"]), duration=1.0
)
env = await actions.request_render(agent_id=uuid4(), composition_id="release-v2")
assert env.error is None
draft = markers.get_video_draft(t)
assert draft is not None
assert draft["composition_id"] == "release-v1"
assert draft["occasion"] == "r1"
# --------------------------------------------------------------------------- #
# QA happy path — read-only branch export, never a working tree
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_qa_happy_path_uses_branch_export(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch, slug="demo-project")
scratch = tmp_path / "scratch"
(scratch / "motion" / "compositions" / "release-v1").mkdir(parents=True)
read_clone = tmp_path / "readclone"
read_clone.mkdir()
workspace = MagicMock()
workspace.export_branch_motion = AsyncMock(return_value=scratch)
workspace.ensure_read_clone = AsyncMock(return_value=read_clone)
t = _task(
draft={"composition_id": "release-v1"},
branch_name="feature/ux_ui/ABCD1234",
)
actions, task_svc = _make_actions(
task_obj=t, role="qa", team=None, workspace=workspace
)
tar_bytes = _make_frames_tar(["frame-0.png"])
_stub_renderer(monkeypatch, frames_tar_gz=tar_bytes, duration=1.0)
env = await actions.request_render(agent_id=uuid4())
assert env.error is None
assert env.evidence is not None
assert env.evidence["source"] == "branch"
assert env.evidence["dirty"] is False
assert len(env.evidence["frames"]) == 1
workspace.export_branch_motion.assert_awaited_once()
called_project, called_branch = workspace.export_branch_motion.call_args.args
assert called_project.slug == "demo-project"
assert called_branch == "feature/ux_ui/ABCD1234"
payload = markers.get_render_preview(t)
assert payload is not None
assert payload["source"] == "branch"
task_svc.heartbeat.assert_awaited_once()
@pytest.mark.asyncio
async def test_qa_without_branch_name_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_arm(monkeypatch)
_stub_project(monkeypatch)
t = _task(draft={"composition_id": "release-v1"}, branch_name=None)
actions, _task_svc = _make_actions(task_obj=t, role="qa", team=None)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# renderer failure
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_video_renderer_error_is_retryable_invalid_state(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch, slug="demo-project")
clone_root = tmp_path / "clone"
(clone_root / "motion" / "compositions" / "release-v1").mkdir(parents=True)
workspace = MagicMock()
workspace.get_clone_root_path.return_value = clone_root
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "x"
t = _task(draft={"composition_id": "release-v1"})
actions, _task_svc = _make_actions(
task_obj=t, role="developer", team="ux_ui", workspace=workspace
)
_stub_renderer(monkeypatch, error=VideoRendererError("sidecar unreachable"))
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
assert "retry" in (env.remediate or "").lower()
assert markers.get_render_preview(t) is None
@@ -64,6 +64,7 @@ def _build_choreographer() -> Choreographer:
"commits>=1",
"pr_open",
"self_verified",
"render_preview",
],
)
def test_hint_registered_for_previously_unhinted_token(token: str) -> None:
@@ -93,6 +94,13 @@ def test_during_work_hint_warns_reflect_doesnt_count() -> None:
), f"during_work hint must warn that scope='reflect' doesn't satisfy this: {hint!r}"
def test_render_preview_hint_mentions_request_render() -> None:
"""The render_preview hint must name the exact next call (request_render)."""
hint = Choreographer._hint_for_missing_key("render_preview", uuid4())
assert hint is not None
assert "request_render" in hint
# ---------------------------------------------------------------------------
# Multi-hint remediate — numbered list, not semicolon-joined
# ---------------------------------------------------------------------------
@@ -0,0 +1,67 @@
"""QA claim_review evidence carries a video-artifact context for
video-source tasks pointing QA at the rendered artifact, not just source."""
from __future__ import annotations
from unittest.mock import MagicMock
from roboco.foundation.policy.content import markers
from roboco.services.gateway.choreographer import Choreographer
from roboco.services.gateway.evidence_builder import build_evidence_for_task
def _stub_task() -> MagicMock:
task = MagicMock()
task.pr_number = None
task.pr_url = None
task.commits = []
task.dev_notes = None
task.acceptance_criteria_status = []
task.source = "code"
task.orchestration_markers = None
return task
def test_video_context_none_for_non_video_task() -> None:
assert Choreographer._qa_video_context(_stub_task()) is None
def test_video_context_present_for_video_task_with_render_preview() -> None:
task = _stub_task()
task.source = markers.VIDEO_TASK_SOURCE
task.orchestration_markers = {
markers.VIDEO_DRAFT: {"composition_id": "intro-v1"},
markers.RENDER_PREVIEW: {"frames": ["a.png", "b.png"]},
}
ctx = Choreographer._qa_video_context(task)
assert ctx is not None
assert ctx["composition_id"] == "intro-v1"
assert ctx["render_preview"] == {"frames": ["a.png", "b.png"]}
assert "request_render" in ctx["note"]
def test_video_context_render_preview_none_without_marker() -> None:
task = _stub_task()
task.source = markers.VIDEO_TASK_SOURCE
task.orchestration_markers = {markers.VIDEO_DRAFT: {"composition_id": "intro-v1"}}
ctx = Choreographer._qa_video_context(task)
assert ctx is not None
assert ctx["composition_id"] == "intro-v1"
assert ctx["render_preview"] is None
def test_evidence_payload_includes_video_context() -> None:
video_context = {"composition_id": "x", "render_preview": None, "note": "n"}
ev = build_evidence_for_task(
_stub_task(),
journal_highlights=[],
files_changed=[],
video_context=video_context,
)
assert ev.as_dict()["video_context"] == video_context
def test_evidence_payload_video_context_default_absent() -> None:
ev = build_evidence_for_task(_stub_task(), journal_highlights=[], files_changed=[])
assert ev.video_context is None
assert "video_context" not in ev.as_dict()