# Recording lanes — how to film a bug's journey on each surface Shared reference for **repro-agent**, **resolve-agent**, and **feature-agent**. They film the same kinds of journeys on the same surfaces; only *which* clip they produce differs: - **repro-agent** films the product journey — a `reproduced` or `likely_repro` facet yields a **`before`** clip showing the live bug (on the stand-in, when one was driven); an `already_fixed` facet yields a **`fixed`** clip showing the correct user-visible outcome. - **resolve-agent** films the product journey after the fix as the **`after`** clip (the human-visible half of the fail→pass proof). The recovered test verifies the result separately. On the review path it films the reviewed PR head. - **feature-agent** films each covered CUJ as a **`demo`** clip, with the CUJ ID as the facet. Its demo is required, not best-effort; its `feature-demo` skill replaces "Internal/API-only results" and "If recording is blocked" below. Everything else below — which surface to drive, how to stand the recorder up, and the per-surface mechanics — is identical for all of them. Each agent's own AGENTS.md says which clip `kind` it produces and where it goes; this file is the how. The recording driver may be an existing test or a temporary journey script, separate from the selected regression coverage. Retain its source and command as evidence for before/after replay. Recording does not require committing the driver as a permanent test. ## When to record Record the user action and the product's response: - **Web, mobile, or desktop:** show the user interacting with the product and the result on screen. - **CLI or terminal output:** record the real command and its output, even if the output is only an error message, hint, or status line. - **Internal/API-only results:** written evidence is enough when no user interface shows the result. Set `recordings: []`, describe what you observed, and explain how you checked it. For example, if `omnigent host` prints the wrong error after login expires, record running that command with an expired login and showing its output. See the `cli` section below for setup steps. ## What the clip must show - Show the actual product behavior. Tests may drive the interaction and verify the result, but the clip must show the product itself. - Do not substitute a video of pytest output, assertions, test source, debug logs, or a slide describing what happened. Do not invent a different journey just to produce a video. - Record one clip per facet, with a `kind` that matches its verdict. Do not add unrelated working examples as extra clips. - An absence is filmable. When the bug is that nothing happens, show the triggering action and the screen staying in the stale state, with whatever error the product does show, for longer than a healthy response takes. ## If recording is blocked Recording is **best-effort**: - If a required tool is missing or the product cannot reach the state you need to show, set `recordings: []` for that facet. Name the specific blocker in `recording_unavailable_reason`, such as missing `vhs` or `ttyd`, or a server that cannot start. - A blocker is the observed result of a recorder run. When your environment check found the recorder available, point it at the facet's lane before declaring the facet blocked, and name the command you ran and its observed error. A reason that names no attempted recorder run — "the recorder was not stood up", a multi-process journey, a flaky boot, a bug that is an absence — is insufficient. - Text-only CLI output is not a reason to skip recording. A missing recording from an earlier run is not a reason either. - Do not block the verdict, fix, or PR because footage is missing or rejected. Explain the gap and continue with the available evidence. ## File names and capture modes Every declared recording includes `capture_mode`, which records how the product surface was filmed. Use `playwright_ui` for browser-page capture, `electron` for the desktop harness, `vhs_user_command` for a VHS tape that types the real user command, and `screen` for a direct device/screen capture. For every lane, save the selected raw capture as `recordings//raw/-.`; reserve `recordings//-.mp4` for the captioned render. Declare only the finished clip after following "Finishing a clip" below. Undeclared media remains available in the CI artifact for debugging but is not attached to Linear or a PR. ## The recorder needs its own local server A `web`/`terminal` recording runs the `tests/e2e_ui/` suite, which drives a live server. Do **not** point it at the app you were launched against: that app is typically auth-gated (a Databricks Apps deployment bounces an unauthenticated Playwright to SSO), so the recorder can't drive it. Let the `tests/e2e_ui/` fixtures **spawn their own local server + runner** (the default when no `--ui-base-url` is passed). ## Build the SPA up front — before you run the recorder, not during it The `tests/e2e_ui/` server serves the SPA from `omnigent/server/static/web-ui/`, which starts empty in your worktree (the deploy's pre-built bundle lives in the serving layer, not the source tree). The suite *can* build it lazily on first boot, but that build pins the machine's cores for a few minutes **while** the spawned runner is trying to tunnel and go online — on a busy CI box the runner misses its online deadline and the fixture reports `online: false`, which looks like an environment failure but is really the build starving the boot. So **always build the SPA first as its own step**, then run the recorder. When you are yourself running inside a server-spawned runner (the `--server` path — check with `echo "$OMNIGENT_RUNNER_ID"`, which is set there), you **must strip the ambient runner/host env vars** so the fixture's own runner starts clean. This is not optional: those vars (`OMNIGENT_RUNNER_ZYGOTE*` FDs, `OMNIGENT_RUNNER_ID`, tunnel/host tokens) leak into the spawned child, make it take the zygote-fork path and block on control FDs it doesn't have, so it hangs with an empty `runner.log` and stays `online: false`. A bare `pytest`/`uv run pytest` with `OMNIGENT_RUNNER_ID` still set is the single most common way the after-clip silently fails to record. Strip them with `env -u`: ```bash # Point npm/pnpm at the Databricks registry proxy first (see # dev/agent-environment.md) or this install fails with an auth error. pnpm --filter web install && pnpm --filter web run build # once, up front env -u OMNIGENT_RUNNER_ID -u OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN \ -u OMNIGENT_RUNNER_TUNNEL_TOKEN -u OMNIGENT_RUNNER_PARENT_PID \ -u OMNIGENT_RUNNER_ISOLATE_SESSION -u OMNIGENT_RUNNER_WORKSPACE \ -u OMNIGENT_HOST_ID -u OMNIGENT_HOST_TOKEN -u OMNIGENT_HOST_NAME \ -u RUNNER_SERVER_URL -u OMNIGENT_REMOTE_AUTH_TOKEN \ $(env | grep -oE '^OMNIGENT_RUNNER_ZYGOTE[A-Z_]*' | sed 's/^/-u /' | tr '\n' ' ') \ OMNIGENT_E2E_RECORD_DIR="$PWD/recordings//raw" \ pytest --screenshot on --output recordings/ ``` Only *after* the SPA is built and the env is stripped is an `online: false` a real environment limit. If you see `online: false` and you have **not** stripped the leaked vars (`OMNIGENT_RUNNER_ID` was set in your shell), that failure is your own un-stripped env, not the harness — re-run with the `env -u` prefix before concluding anything; do **not** file it as an environmental blocker or a "runner not coming online" env problem. Once the SPA is built, the env is stripped, and the spawned runner still doesn't reach `online: true` within the fixture's timeout, capture the tail of the fixture's `runner.log`, treat that lane as genuinely unreachable here, keep `recordings: []` for it, and say plainly **"recorder's test server did not come online in time"** with the `runner.log` tail — noting whether the log was empty (the leaked-env/zygote hang) or showed a later failure, so the cause is named from what you observed rather than guessed. ## `web` facets Run the selected Playwright journey driver with recording on. **Record via `OMNIGENT_E2E_RECORD_DIR`, not `--video on`.** `--video on` only instruments pytest-playwright's own `page` fixture. Many e2e_ui tests (e.g. the whole `tests/e2e_ui/start_session/` suite) drive Playwright *manually* — `async_playwright()` + `browser.new_page()`/`new_context()` — instead of taking the `page` fixture, and `--video on` records **nothing** for those: the flag never sees their browser, so you get a green/red test but an empty `recordings/`. Setting `OMNIGENT_E2E_RECORD_DIR` makes the e2e_ui conftest inject `record_video_dir` into pytest-playwright's `page`/`context` fixtures and into every page/context the test opens through the async or sync API, so the journey is filmed no matter how the test opened the browser. Playwright writes the `.webm` (a random hash name) into that dir when the context closes. (If a test already hard-codes its own `record_video_dir` — some authored reproductions do — that explicit dir wins and the video lands there instead; check both locations.) For pytest-playwright fixtures, `--video` takes precedence over the environment variable: clips go to its output directory, and `--video retain-on-failure` discards passing-test videos. **The clip ends with the test body, not with fixture teardown.** Playwright writes the video when the recorded context closes, and pytest tears fixtures down in reverse setup order: the `context` behind `page` is set up before the session fixtures a test lists after it, so it closes *last* — after those fixtures have deleted the session or stopped the runner. A clip that keeps rolling through teardown ends on the SPA's teardown state instead of the state the test asserted: an `after` clip on the pane greyed out behind "Bridge closed: terminal session ended", a `before` clip on a search palette that drops to "No results found" as the fixture deletes the sessions it had just listed. When recording is requested (`OMNIGENT_E2E_RECORD_DIR`, `--video`, or a `record_video_dir` in `browser_context_args`), the e2e_ui conftest therefore closes the pytest-playwright context as soon as the test body finishes, before any fixture teardown runs. A test that opens its own pages or contexts must close them itself before its body returns, for the same reason, and a fixture that touches `page` after `yield` must skip that work when `page.is_closed()`. Recording configured with a `@pytest.mark.browser_context_args(record_video_dir=...)` marker follows the same stop point. **Move the emitted clip to a stable name.** The video lands under `OMNIGENT_E2E_RECORD_DIR` (or the test's own dir) as a random hash name; **move** it (do not copy) to `recordings//raw/-.webm`. Keep that source for caption rendering below and declare only the selected finished clip, so raw footage is retained as supporting evidence rather than attached twice. If that dir has **no** `.webm` after the run, the recording genuinely didn't happen (the test errored before opening a page, or the fixture never came online). For a test using the `page` fixture, retry with `--video on` and check pytest's `--output` directory before declaring the lane unfilmable. A clip there indicates a recorder configuration problem. Then capture the reason per the empty-recordings rule; never report a clip you didn't produce. **Don't poll clipped or element screenshots while filming.** Chromium takes `page.screenshot(clip=…)` and `locator.screenshot()` by resizing the view to the clip for the capture, and the recorder films that resized view: a pixel probe polled during the journey leaves the footage mostly grey with a shrunken or magnified strip of the page. The `tests/e2e_ui/` conftest turns a clipped `page.screenshot` on a recorded Chromium page into a full-viewport capture cropped to the clip, so viewport clip probes there are safe. `full_page=True` captures are not covered and can still resize the recorded view. For element screenshots, or a Playwright script outside that suite, read pixels via `page.evaluate` (e.g. a canvas `toDataURL()`) or capture from a second, unrecorded context instead. Clipped JPEGs on recorded Chromium pages are encoded with Pillow, so they are not byte-identical to Chromium's native JPEG output. Unsupported formats and malformed screenshot options use Playwright's native validation and errors. **Maintenance:** Context closure before fixture teardown and screenshot crop rounding rely on pytest-playwright and Chromium internals; after Playwright, pytest-playwright, or Chromium upgrades, rerun `tests/e2e_ui/test_recorded_page_clip_screenshots.py` and `tests/e2e_ui/test_recording_stops_before_teardown.py` as canaries. ## `mobile` facets The iOS/Android apps are thin native shells that load the *same* server-served SPA in a WebView, so almost every mobile bug is a web-UI journey that only misbehaves at a phone viewport or under touch (Enter vs. newline on a small composer, a control that overflows off-screen, a touch-scroll that doesn't take). Film these on the **web lane at a mobile device profile** — pytest-playwright ships Playwright's device registry, so add `--device` to the same recorder invocation and the whole context becomes mobile (phone viewport, `is_mobile`, touch, a mobile user-agent), video included: ```bash OMNIGENT_E2E_RECORD_DIR="$PWD/recordings//raw" \ pytest --device "iPhone 13" --screenshot on --output recordings/ ``` Pick a device whose form factor matches the report (`"iPhone 13"`, `"Pixel 7"`, an `... landscape` variant). Everything else about the `web` lane applies unchanged (build the SPA first, strip the runner env, record via `OMNIGENT_E2E_RECORD_DIR`, move the emitted clip to a stable name). Author the test itself with touch/tap interactions where the journey needs them. Stamp the facet `mobile` (it's a mobile-surface bug) even though the recorder is the browser at a phone profile — the surface is where the *user* sees it, not the tool that films it. A **small minority** of mobile bugs live in native chrome the WebView profile can't show — iOS safe-area / Dynamic Island insets, the system-browser OIDC hop, the native setup screen. Those need a real simulator/emulator screen recording, which no CI path provisions today; keep `recordings: []` for such a facet and name the missing device toolchain in your evidence (a real environment limit, not a `not_reproduced`). Keep the available test evidence and selected regression coverage; missing footage does not change which tests belong in the PR. ## `terminal` facets The pane renders inside the web app, so record it the same way as `web`: the Playwright test drives the session page with the terminal view shown, and the pane's contents land in the browser video. Save `tmux capture-pane -e` text dumps alongside as machine-checkable evidence. **For a native-harness pane** (claude/codex/cursor/goose/hermes/kiro/… — the bug is in a real harness CLI's output), don't hand-roll the launch: the existing render-parity tests already drive the *real* CLI against the mock LLM with the terminal view shown, so **reuse or adapt the closest one** (`tests/e2e_ui/messages/test_native__render_parity.py`, which use the `native__session` / `native__mock_session` fixtures in `tests/e2e_ui/conftest.py`) and adapt its scripted turns to your journey. These tests skip when the harness CLI isn't installed; if the one your bug needs is unavailable here, keep `recordings: []` and name the missing CLI in your evidence (a real environment limit, not a `not_reproduced`). ## `cli` facets Author/replay a VHS tape (`recordings//journey.tape`) that replays the SAME numbered journey steps as the PTY test: `Type`/`Enter` the user's commands, `Wait /pattern/` on the observable outcome, with an `Output recordings//raw/-.mp4` directive. Create the `raw/` directory first. Render with `vhs recordings//journey.tape`. If `vhs` is unavailable, still keep the tape and note that rendering was skipped. - **Boot any server/host the journey needs BEFORE the tape drives it** — don't make the tape do the slow startup. If the tape's own commands start the server, VHS's per-command timeout fires during the multi-second boot and the render dies half-way (the cli-lane analog of the web recorder-server race). Start the server/host as a prior shell step, then have the tape `Type` only the user's journey commands against the already-running process. Keep the tape's own steps fast (sub-second each) so no `Wait` straddles a boot. - **Film the REAL command the user runs — not an API-call stand-in.** When the failure surfaces as a command's console output, the tape must run *that command* and capture *its* output, even when reproducing it needs a precondition you stage first. Do **not** substitute a `curl`/`httpx`/Python poke of the underlying endpoint — that films the mechanism, not the failure the user sees. Example: for "host 403s after its login expires", seed an **expired** `auth_tokens.json` (a past `expires_at`), then `Type` the actual `omnigent host --server ` and `Wait` for the console failure it prints. Only when the command genuinely can't be driven here (name why) fall back to `recordings: []`. - **End the tape on the OUTPUT, not a fixed timer.** End on a `Wait //` matching the observable result line (the error text, the value, the exit message), with only a short trailing `Sleep` after it lands — never a bare `Sleep`/short total duration as the stop condition. The clip must show the outcome, not the moment before it. - **A clip of the reproduction TEST running is NOT the journey.** If the tape won't render (server boot times out, `ttyd` missing, VHS unavailable), do **not** substitute a recording of `pytest … FAILS` / an `AssertionError`. That films the regression artifact, not the failure a user sees. Keep `recordings: []` and name the blocker. Keep the test evidence separately and commit only the selected permanent regression coverage. ## `desktop` facets (Electron shell) When the failure lives in the desktop shell itself (the dead-end 401 fallback to the setup page, in-window IdP rendering, the session-expiry reload, the OAuth-popup / window-open policy, the native host-enrollment dialog) rather than in the SPA, the browser tools and the `tests/e2e_ui/` Playwright lane can't reach it: the defect is in Electron's main process, and Python Playwright has **no** Electron API. Use the JS desktop lane in `web/electron/e2e/` instead — **copy the reference test `desktop_connect.e2e.js`**, which launches the REAL packaged shell under `_electron.launch({ recordVideo })` (via `desktopHarness.js`, which spawns the same mock-LLM + `omnigent server` pair the Python suite does) and films the actual window. Pass `serverUrl` to `launchDesktop` when the failure is *past* connect (boot straight into the shell); omit it to film the connect/setup/fallback flow itself. Run with `node --test e2e/desktop_.e2e.js` from `web/electron`, after building the SPA. On a headless box wrap it in `xvfb-run -a` and set `OMNIGENT_PW_NO_SANDBOX=1` so Electron's Chromium starts (repro-agent CI sets both for you, and points `OMNIGENT_PYTHON` at the venv the harness spawns the server with). This lane needs `electron` + `playwright` on disk (neither is in the fast `web-test` CI path); the harness **skips** cleanly when they're absent, so if they can't be installed here keep `recordings: []` and name the missing dep in your evidence (a real environment limit, not a `not_reproduced`). See `web/electron/e2e/README.md`. ## Finishing a clip A recording must end on the outcome the user observes — the failure (wrong screen state, bad output, error) for a `before` recording, or the correct end state for a `fixed`/`after` one. Keep its capture format under `raw/`; the caption helper produces the final `.mp4`, so no preliminary conversion is needed. Recordings are workspace artifacts exactly like the test — leave them uncommitted; in CI the artifact bundle collects them. Check the clip's last frame before captioning it. Every clip must stop on the demonstrated state its caption describes — the failure for a `before` clip, the healthy state for a `fixed`/`after` clip; a final frame that shows teardown — the pane greyed out behind "Bridge closed: terminal session ended", a deleted session, a result list emptied by fixture cleanup — means the recording outlived the test body. Fix the stop point and re-record; do not caption around it. Base screen claims on inspected frames or visible-state assertions from that recording. Backend logs and transcript storage can differ from what the UI has already displayed. Keep backend findings in `evidence`; describe the visible outcome in the caption. Check each claimed moment, especially claims that text or controls are absent. If the frames contradict the caption, correct it or re-record. Verify that each captioned element is in the recorded viewport and unobstructed. Quote only text the viewer can see: DOM attributes and hidden accessibility labels do not establish what the screen displayed. Describe icon-only controls by their visible appearance. If frame inspection is unavailable, disclose that and limit claims to states the recording driver actually verified. A driver check covers only the element it targets. The screen can show one state in several places: a session's activity appears as the chat pane's working indicator, a spinner on its sidebar row, and a sub-agent count badge. An absence check on the chat pane's indicator supports "the chat pane shows no working indicator", not "the session shows no activity". Check every such place before claiming that none shows the state. ### Timed captions inside the video For every newly captured clip, **burn in step-by-step captions before declaring the recording**. This applies to Repro's `before`/`fixed` clips and Resolve's `after` clips on both the author and review paths, across all capture surfaces. Keep recovered before-clips unchanged. The handoff's prose `caption` appears below the attachment; it does not replace captions visible during playback. 1. Watch the raw clip and note timestamps relative to its first frame (seconds). Timing logged by the driver can help, but check it against the actual video; browser creation, navigation and recording do not necessarily start together. 2. Write `recordings//-.cues.json`, a JSON array of `{"start": 0, "end": 3.5, "text": "1. Open the model picker."}` objects. Use ordered, non-overlapping intervals within the source duration. Cover each meaningful action and the final observed result; leave text up long enough to read (usually 2–5 seconds). Use short, plain sentences, at most three lines. Whitespace, including explicit newlines, is normalized; the renderer wraps text to fit the caption band automatically. If the action is too fast to explain, re-record with a brief natural pause. 3. Explain the setup that changes how to interpret the result (OS/version, scripted model, injected failure, shortened timeout) in an opening cue. Then describe what happens now: "2. Select the catalog", "3. The picker shows raw IDs". End with the **observed** result for this attempt, including limitations. A passing attempt is not proof that a reported bug never occurs. Do not claim that a permission was bypassed, a command ran, or a fix worked unless the footage/evidence establishes it. Label any still-image close-up, speed change or cut; the helper below makes none of these edits. 4. Render with the shared helper (Python 3.10+ standard library; requires `ffmpeg` with `drawtext`, `ffprobe`, and DejaVu Sans Mono; on Ubuntu install `ffmpeg` and `fonts-dejavu-core`): ```bash python3 dev/caption_recording.py \ --source recordings//raw/-.webm \ --cues recordings//-.cues.json \ --output recordings//-.mp4 ``` For VHS or device captures already in MP4, use `--source recordings//raw/-.mp4` with the same output path. For Electron or direct screen captures, move the selected capture under `raw/` first, preserving its extension. Run the helper from the Omnigent tools checkout if the target repository is different. It pads below the original screen so captions do not cover controls or output, retains timing and audio, and refuses to overwrite existing files. Media probes time out after 30 seconds and rendering after 10 minutes by default. For long or high-resolution clips, increase `--render-timeout` (seconds); a stalled encoder still reports a bounded captioning failure. Always render from raw footage; choose a fresh output path when revising cues. Keep the raw clip, cue JSON and render command as uncommitted workspace evidence. Once the selected capture is under `raw/`, retain it for rerendering; do not delete it as a duplicate or declare it alongside the finished clip. 5. Play the result and inspect the opening, each caption transition, and the final frame. Confirm text is legible, fits in the band, matches the action's timing, and describes only what was observed. Declare only the inspected, captioned MP4 under the stable `-.mp4` name in the handoff and PR Demo. CI's later media normalization preserves the burned-in text. Captioning is best effort, like capture. If the renderer, font or filter is unavailable, or rendering fails and cannot be repaired, copy the usable source from `recordings//raw/-.` to `recordings//uncaptioned--.`, preserving its format and the raw source. Use a new destination if that fallback name already exists; never overwrite another recording. Inspect the copy, then declare only that actual fallback path and matching `format` in the handoff and PR Demo. Do not declare the failed render's path or the raw source as an additional recording. Explain the captioning blocker in `evidence` and label the attachment as uncaptioned. This fallback does not occupy the final captioned MP4 path. Do not discard valid footage or block the verdict, fix, or PR just because captions could not be rendered. Do not claim an unrendered cue file is a captioned recording. For each recording, also write a short **`caption`** in its handoff entry describing **the actions that clip performs** — the ordered steps a viewer watches, ending in what the clip shows: e.g. `"start a session → open the model picker → select the catalog → picker shows raw IDs"`. This is what a reader sees under the video on the ticket, so make it read like a journey, not a restatement of the bug title.