项目文件夹

文件
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

593 行
30 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""VCR replay of the full-lifecycle Deep Research polling loop.
This module captures the Deep Research polling loop —
``START_DEEP_RESEARCH`` plus a poll-until-terminal sequence of
``POLL_RESEARCH`` calls that reaches the ``completed`` terminal state — and
replays it with ``asyncio.sleep`` monkey-patched to a no-op so the test runs
in milliseconds.
Lifecycle scope
---------------
The recording goal is the **full** Deep Research lifecycle:
``START → N polls → completed``. Earlier attempts to record this scoped the
cassette down to a handful of in-progress polls because two consecutive
recording attempts on 2026-05-15 both failed with ``httpx.PoolTimeout``
mid-poll (after ~22 min / 31 polls the first time, ~14 min the second). The
root cause was stale-connection reuse: the default 30 s keepalive expiry plus
the multi-minute idle gaps between polls left the pool handing back a
half-dead connection on a later poll, which then timed out acquiring from the
pool.
This recording dodges that with PoolTimeout-resilient client config applied
**only in record mode** (see :func:`_recording_client_kwargs`): a generous
pool-acquire timeout plus a short ``keepalive_expiry`` so a long-idle
connection is closed proactively and a fresh one is opened on the next poll.
Both knobs are reached through the **public** ``NotebookLMClient``
constructor (``timeout`` maps to httpx's ``pool`` acquire timeout;
``limits=ConnectionLimits(keepalive_expiry=...)`` controls idle-connection
expiry), so no private seam is needed. Replay is unaffected — the resilient
kwargs are only applied when ``NOTEBOOKLM_VCR_RECORD`` is set.
Poll-until-terminal
-------------------
:func:`_poll_until_terminal` polls ``client.research.poll`` every
:data:`_POLL_INTERVAL_SECONDS` until ``poll.status`` is a TERMINAL
:class:`~notebooklm._types.research.ResearchStatus` (``COMPLETED`` or
``FAILED``), with a hard cap of :data:`_MAX_POLLS` so a hung run can't spin
forever. Task-id pinning mirrors
:meth:`ResearchAPI.wait_for_completion`: the first poll is unfiltered, and the
``task_id`` the POLL endpoint reports is captured and threaded through later
polls. This is load-bearing for Deep Research — the id from
:meth:`research.start` does NOT equal the poll-reported id (verified live), so
threading start's id would make every poll return ``NOT_FOUND``. During replay
the cassette plays each recorded response back in order, so the replay loop
performs exactly the recorded number of polls and lands on the recorded
terminal status.
Source query
------------
``"Compare the key themes across the sources"`` against a scratch notebook
seeded with three substantive Wikipedia paragraphs (well-known public
encyclopaedia content — no PII, no proprietary text). The exact source titles
and bodies are stored in :data:`_SCRATCH_SOURCES`. The query is intentionally
broad so Deep Research actually does the multi-step web-research walk rather
than short-circuiting on a trivial answer.
Sleep-mock pattern (reused from ``test_polling_vcr``)
-----------------------------------------------------
The poll loop calls ``await asyncio.sleep(...)`` between polls to space
requests out. During cassette replay those sleeps add nothing — the cassette
already encodes the server's progression — so we patch ``asyncio.sleep`` to an
immediate no-op via the ``fast_sleep`` fixture. The fixture is intentionally
narrow: only ``asyncio.sleep`` is replaced; anything else that legitimately
needs to wait is unaffected. During RECORDING the patch is a no-op so the real
poll cadence is preserved.
Recording
---------
Recording captures (in a single cassette) the scratch-notebook lifecycle:
1. ``CREATE_NOTEBOOK`` — fresh scratch notebook.
2. Three ``ADD_TEXT_SOURCE`` calls — substantive Wikipedia paragraphs.
3. ``START_DEEP_RESEARCH`` — kicks off Deep Research on the seeded notebook.
4. ``POLL_RESEARCH`` interactions — polled until the task reaches
``completed`` (no_research → in_progress → completed progression).
5. ``DELETE_NOTEBOOK`` — scratch notebook cleanup.
To re-record::
export NOTEBOOKLM_VCR_RECORD=1
uv run pytest tests/integration/test_research_deep_poll_vcr.py -v -s
Deep Research is a multi-minute server-side operation, so the recording can
take 20–40 minutes of real wall-clock time. Use ``-s`` to watch the per-poll
progress logging.
Replay
------
``@notebooklm_vcr.use_cassette`` plus ``fast_sleep`` makes the full flow run
in <30 seconds. The default VCR matcher uses ``rpcids`` so the
create / add_text / start / poll / delete interactions are disambiguated by
query string; the repeated ``POLL_RESEARCH`` interactions match by play-count
order (VCR's default for same-key requests), which is exactly the sequential
consumption the poll loop performs.
Trimming
--------
If a full recording exceeds the 5 MB size cap (see
:func:`test_cassette_under_size_cap`), redundant *middle* ``in_progress``
polls may be removed by hand from the cassette, keeping
``START_DEEP_RESEARCH`` + the first couple ``no_research`` / ``in_progress``
polls + the ``completed`` poll + the notebook create / add_text / delete
lifecycle. The trimmed sequence still replays a faithful
no_research → in_progress → completed progression because the poll loop
consumes exactly the polls present and stops on the terminal status.
"""
from __future__ import annotations
import asyncio
import os
import uuid
from pathlib import Path
from typing import Any
import pytest
import yaml
from notebooklm import NotebookLMClient
from notebooklm.rpc import RPCMethod
from notebooklm.types import ConnectionLimits, ResearchStatus
from tests.integration.conftest import get_vcr_auth, skip_no_cassettes
from tests.vcr_config import notebooklm_vcr
pytestmark = [pytest.mark.vcr, skip_no_cassettes]
CASSETTE_NAME = "research_deep_poll_long.yaml"
CASSETTE_PATH = Path(__file__).parent.parent / "cassettes" / CASSETTE_NAME
# Minimum POLL_RESEARCH interactions the cassette must contain to be
# meaningful. A full-lifecycle recording reaches ``completed`` after several
# polls; this floor catches a recording-script regression that captured only
# one or two polls before terminal. Kept low so legitimate hand-trimming of
# redundant middle in_progress polls (down to a no_research → in_progress →
# completed skeleton) stays above the floor.
MIN_POLL_INTERACTIONS = 3
# Terminal research states — the poll-until-terminal loop stops on either.
_TERMINAL_STATUSES: frozenset[ResearchStatus] = frozenset(
{ResearchStatus.COMPLETED, ResearchStatus.FAILED}
)
# Hard cap on the number of polls during recording so a hung Deep Research run
# can't spin forever. At :data:`_POLL_INTERVAL_SECONDS` (30 s) this is roughly
# 40 minutes of wall-clock — comfortably longer than a typical deep run but
# bounded.
_MAX_POLLS = 80
# Per-test timeout override. The suite sets a global 60 s ``pytest-timeout``
# (CI hang safety net), which is far too short for the live recording — the
# poll-until-terminal loop can run ~40 min against the live API. We override
# with a wall-clock ceiling that covers the worst case
# (``_MAX_POLLS`` polls × ``_POLL_INTERVAL_SECONDS`` + per-poll RPC time +
# notebook setup) with headroom. During REPLAY the loop finishes in <30 s, so
# the high ceiling is inert; it only matters when recording.
_RECORD_TEST_TIMEOUT_SECONDS = 3600
# Source content for the scratch notebook. Three substantive Wikipedia
# paragraphs on distinct topics so Deep Research has something thematic to
# compare. Content is public-domain encyclopaedia text — no PII.
_SCRATCH_SOURCES: tuple[tuple[str, str], ...] = (
(
"Photosynthesis (Wikipedia excerpt)",
(
"Photosynthesis is a biological process used by plants, algae, and "
"certain bacteria to convert light energy, typically from the Sun, "
"into chemical energy stored in organic compounds such as sugars. "
"Most photosynthetic organisms also produce oxygen as a byproduct, "
"and the oxygen released into the atmosphere maintains the aerobic "
"respiration that most of Earth's life depends on. Photosynthetic "
"organisms are called photoautotrophs because they produce their "
"own food using light. In plants, algae, and cyanobacteria, "
"photosynthesis releases oxygen, in what is called oxygenic "
"photosynthesis. The light-dependent reactions take place on the "
"thylakoid membranes of the chloroplasts; the light-independent "
"reactions (the Calvin cycle) take place in the stroma."
),
),
(
"Industrial Revolution (Wikipedia excerpt)",
(
"The Industrial Revolution, sometimes divided into the First "
"Industrial Revolution and Second Industrial Revolution, was a "
"period of global transition of the human economy towards more "
"efficient and stable manufacturing processes that succeeded the "
"Agricultural Revolution, starting from Great Britain and "
"continental Europe and the United States, that occurred during "
"the period from around 1760 to about 1820–1840. This transition "
"included going from hand production methods to machines; new "
"chemical manufacturing and iron production processes; the "
"increasing use of water power and steam power; the development "
"of machine tools; and the rise of the mechanised factory system."
),
),
(
"Quantum mechanics (Wikipedia excerpt)",
(
"Quantum mechanics is a fundamental theory in physics that "
"describes the behavior of nature at and below the scale of atoms. "
"It is the foundation of all quantum physics including quantum "
"chemistry, quantum field theory, quantum technology, and quantum "
"information science. Classical physics, the collection of "
"theories that existed before the advent of quantum mechanics, "
"describes many aspects of nature at an ordinary (macroscopic) "
"scale, but is not sufficient for describing them at small "
"(atomic and subatomic) scales. Most theories in classical "
"physics can be derived from quantum mechanics as an "
"approximation valid at large (macroscopic) scale. Quantum "
"mechanics differs from classical physics in that energy, "
"momentum, angular momentum, and other quantities of a bound "
"system are restricted to discrete values (quantization)."
),
),
)
_RESEARCH_QUERY = "Compare the key themes across the sources"
# Poll-loop tuning. During replay the sleeps are mocked out, so this only
# affects the live recording: 30-second intervals between polls so we don't
# hammer the API across the multi-minute deep-research walk.
_POLL_INTERVAL_SECONDS = 30.0
# Generous pool-acquire / read timeout for the recording client. The public
# ``NotebookLMClient(timeout=...)`` float maps to httpx's ``pool`` (connection-
# acquire) timeout AND the read/write timeouts (``Kernel.open`` sets
# ``read=write=pool=timeout``), so a single value covers both "don't raise
# PoolTimeout on a brief pool-contention spike" and "tolerate a slow poll
# response".
_RECORD_TIMEOUT_SECONDS = 60.0
# PoolTimeout-resilient connection limits for the recording client: a SHORT
# ``keepalive_expiry`` (10 s) so a connection left idle across the multi-minute
# gap between polls is closed proactively and the next poll opens a fresh one.
# The previous full-lifecycle recordings died with ``httpx.PoolTimeout`` from
# stale-connection reuse — the default 30 s keepalive let the pool hand back a
# half-dead connection on a later poll.
_RECORD_LIMITS = ConnectionLimits(
max_connections=100,
max_keepalive_connections=20,
keepalive_expiry=10.0,
)
def _is_record_mode() -> bool:
"""True when ``NOTEBOOKLM_VCR_RECORD`` enables record mode."""
return os.environ.get("NOTEBOOKLM_VCR_RECORD", "").lower() in ("1", "true", "yes")
def _recording_client_kwargs() -> dict[str, Any]:
"""PoolTimeout-resilient ``NotebookLMClient`` kwargs for RECORD mode.
Returns the public-constructor kwargs that harden the recording client
against the long idle poll window that aborted the previous full-lifecycle
recordings:
* ``timeout`` — a generous pool-acquire / read timeout
(:data:`_RECORD_TIMEOUT_SECONDS`) so a brief pool-contention spike or a
slow poll response doesn't raise ``PoolTimeout`` / ``ReadTimeout``.
* ``limits`` — :data:`_RECORD_LIMITS` with a SHORT ``keepalive_expiry`` so
a connection left idle across the multi-minute gap between polls is
closed proactively and the next poll opens a fresh one.
Returns an empty dict outside record mode, so replay uses the default
client config and the cassette plays back unchanged.
"""
if not _is_record_mode():
return {}
return {"timeout": _RECORD_TIMEOUT_SECONDS, "limits": _RECORD_LIMITS}
@pytest.fixture
def fast_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
"""Monkey-patch ``asyncio.sleep`` to an immediate no-op during REPLAY.
The poll loop interleaves ``POLL_RESEARCH`` RPCs with
``await asyncio.sleep(interval)`` for backoff. During cassette replay the
wait adds nothing — the cassette already encodes server progression — so
we replace ``asyncio.sleep`` with an immediate no-op.
During RECORDING (``NOTEBOOKLM_VCR_RECORD=1``) the patch is a no-op so the
live poll cadence is preserved — Deep Research is a multi-minute
server-side operation and we want real spacing between polls so we don't
hammer the API with hundreds of duplicate POLL_RESEARCH calls. Without
this guard, recording would behave like a tight spin-loop and likely
trigger rate limiting before the research completed.
The fixture is narrow on purpose: only ``asyncio.sleep`` itself is
patched, so anything else that genuinely needs to wait (test setup,
library-internal awaits that don't go through ``asyncio.sleep``) is
untouched.
"""
if _is_record_mode():
# Record mode — preserve real cadence so the live API isn't spammed.
return
async def instant_sleep(_seconds: float, result: object | None = None) -> object | None:
# Preserve ``asyncio.sleep``'s full signature: it accepts an optional
# ``result`` value to return after the sleep. Nothing in this repo
# currently uses it, but matching the stdlib signature keeps the
# monkey-patch drop-in for any future caller.
return result
monkeypatch.setattr(asyncio, "sleep", instant_sleep)
async def _poll_until_terminal(
client: NotebookLMClient,
notebook_id: str,
) -> list[Any]:
"""Poll Deep Research until a terminal status, returning every poll result.
Polls ``client.research.poll`` every :data:`_POLL_INTERVAL_SECONDS` until
``poll.status`` is a TERMINAL :class:`ResearchStatus` (``COMPLETED`` or
``FAILED``). A hard cap of :data:`_MAX_POLLS` bounds the loop so a hung run
can't spin forever.
Task-id pinning mirrors :meth:`ResearchAPI.wait_for_completion`: the FIRST
poll is unfiltered (``task_id=None``), and the ``task_id`` the POLL
endpoint reports for the single in-flight task is captured and threaded
through subsequent polls as the discriminator. This is load-bearing for
Deep Research — the ``task_id`` returned by :meth:`research.start` does NOT
equal the one the poll endpoint reports (verified live: start returns one
UUID, poll reports a different UUID for the same task), so threading
start's id would make every poll return ``NOT_FOUND``. The unfiltered
first poll is unambiguous because the scratch notebook has exactly one
research task in flight.
Only ``COMPLETED`` and ``FAILED`` break the loop. ``NOT_FOUND`` /
``NO_RESEARCH`` are NOT treated as terminal — they continue polling
(mirrors ``wait_for_completion``'s replication-lag policy: a pinned task
temporarily absent from a poll is a transient condition, not an end
state), bounded by :data:`_MAX_POLLS`.
The result list is returned in poll order. During replay the cassette
plays each recorded poll response back in order, so the loop performs
exactly the recorded number of polls and lands on the recorded terminal
status.
"""
results: list[Any] = []
pinned_task_id: str | None = None
for poll_index in range(_MAX_POLLS):
poll = await client.research.poll(notebook_id, task_id=pinned_task_id)
# Defensive narrowing: poll() returns a ResearchTask (never None), but
# asserting keeps the fail-fast invariant explicit and guards the
# attribute access below against an unexpected empty response.
assert poll is not None, "research.poll must return a ResearchTask, not None"
results.append(poll)
# Capture the POLL-reported task id the first time the task surfaces,
# then pin it for every later poll (mirrors wait_for_completion).
if pinned_task_id is None and poll.task_id:
pinned_task_id = poll.task_id
if _is_record_mode():
print( # noqa: T201 — record-mode progress, visible under pytest -s
f"[deep-research record] poll {poll_index + 1}/{_MAX_POLLS}: "
f"status={poll.status} task_id={poll.task_id or '<none>'}"
)
if poll.status in _TERMINAL_STATUSES:
break
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
return results
class TestDeepResearchPollReplay:
"""Replays the full deep-research lifecycle to ``completed`` in <30 seconds."""
# ``pytest.mark.vcr`` is applied module-wide via ``pytestmark`` — no need
# to repeat it here.
@pytest.mark.timeout(_RECORD_TEST_TIMEOUT_SECONDS)
@pytest.mark.asyncio
@notebooklm_vcr.use_cassette(CASSETTE_NAME)
async def test_deep_research_polling_loop(self, fast_sleep: None) -> None:
"""Create scratch notebook → add sources → deep research → poll to completed → cleanup.
Drives the poll-until-terminal loop: polls until the task reaches a
terminal :class:`ResearchStatus`, then asserts the final status is
``COMPLETED``. Replay validates that the client correctly threads
``task_id`` through each poll AND that the recorded lifecycle reaches
the ``completed`` terminal state (not just exercises the iteration
path).
"""
auth = await get_vcr_auth()
# PoolTimeout-resilient client config ONLY in record mode (empty kwargs
# in replay, so the cassette plays back against the default config).
async with NotebookLMClient(auth, **_recording_client_kwargs()) as client:
# 1. Fresh scratch notebook. The UUID suffix keeps re-records
# distinct even if a previous run leaked an undeleted notebook
# into the account.
scratch_title = f"T8.E7 deep-research scratch {uuid.uuid4().hex[:8]}"
notebook = await client.notebooks.create(scratch_title)
assert notebook is not None
notebook_id = notebook.id
assert notebook_id, "create() must return a notebook with an id"
try:
# 2. Seed the notebook with three substantive text sources so
# Deep Research has thematic material to compare.
for title, content in _SCRATCH_SOURCES:
source = await client.sources.add_text(
notebook_id, title=title, content=content
)
assert source is not None
assert source.id, "add_text() must return a source with an id"
# 3. Kick off Deep Research.
start_result = await client.research.start(
notebook_id,
query=_RESEARCH_QUERY,
source="web",
mode="deep",
)
assert start_result is not None
start_task_id = start_result.task_id
assert start_task_id, "research.start must return a task_id"
assert start_result.mode == "deep"
# 4. Poll until terminal. Drives polls every
# _POLL_INTERVAL_SECONDS in record mode; during replay the
# cassette plays each recorded response in order until the
# terminal status is consumed. The poll loop pins the
# POLL-reported task id (NOT start's id — they differ for
# Deep Research; see _poll_until_terminal).
polls = await _poll_until_terminal(client, notebook_id)
assert len(polls) >= 1, "poll loop must make at least one poll"
# Every poll must return a status-shaped result. We don't pin
# the intermediate statuses because the API emits several
# states across the loop:
# * ``no_research`` — early polls before Deep Research has
# registered the task in the poll endpoint (empty task_id).
# * ``in_progress`` — once the task is visible the poll echoes
# a task_id back so callers can correlate.
# * ``completed`` — terminal.
#
# Once the task surfaces, its POLL-reported task_id must be
# stable across the rest of the loop (the same task, not a
# sibling). We assert internal consistency of the poll stream
# rather than equality with start's id, because Deep Research's
# start id and poll id differ by design.
seen_poll_task_id: str | None = None
polls_with_task_id = 0
for poll in polls:
assert poll.status
poll_task_id = poll.task_id
if poll_task_id:
polls_with_task_id += 1
if seen_poll_task_id is None:
seen_poll_task_id = poll_task_id
else:
assert poll_task_id == seen_poll_task_id, (
"poll-reported task_id changed mid-loop: "
f"{seen_poll_task_id!r} -> {poll_task_id!r}"
)
# The cassette must actually EXERCISE the task-id pinning path
# this PR introduces: at least two polls must surface the same
# non-empty task_id, so a later filtered poll reuses the id the
# first sighting captured. A cassette where task_id only appears
# on the final terminal poll would pass the consistency check
# above without the pinned-filter path ever running.
assert polls_with_task_id >= 2, (
"expected the cassette to include at least two polls with "
"the same non-empty task_id so replay exercises task-id "
f"pinning; saw {polls_with_task_id}."
)
# The recorded lifecycle MUST reach a terminal state, and that
# terminal state MUST be ``COMPLETED`` (enum membership, not a
# brittle string). This is the whole point of the full-
# lifecycle recording: the prior scoped cassette never reached
# a terminal poll.
final = polls[-1]
assert final.status in _TERMINAL_STATUSES, (
f"poll loop ended on non-terminal status {final.status!r}; "
"the cassette must record a poll reaching a terminal state."
)
assert final.status == ResearchStatus.COMPLETED, (
f"expected the recorded lifecycle to reach COMPLETED, got {final.status!r}."
)
finally:
# 5. Cleanup — runs in record AND replay (the cassette has a
# DELETE_NOTEBOOK interaction for the replay to consume).
# Using ``finally`` so a mid-flow failure during recording
# still drops the scratch notebook.
await client.notebooks.delete(notebook_id)
def test_cassette_reaches_completed(self) -> None:
"""The cassette must capture a poll-to-completion lifecycle.
Asserts that the cassette contains at least
:data:`MIN_POLL_INTERACTIONS` ``POLL_RESEARCH`` (``e3bVqc``)
interactions plus the bookend RPCs (CREATE_NOTEBOOK, three
ADD_TEXT_SOURCE, START_DEEP_RESEARCH, DELETE_NOTEBOOK), and that the
final POLL_RESEARCH interaction decodes to a ``COMPLETED`` research
task.
The terminal-status check decodes the last poll body through the
project's own ``decode_response`` + ``parse_research_task_models``
pipeline rather than grepping the raw body for the literal string
``"completed"`` — the wire payload encodes the status numerically, so
a substring grep would never find the word. Decoding through the real
parser keeps the assertion faithful to what the client sees while
still parsing the cassette directly (independent of the live API).
"""
# Imported here (not at module top) to keep the decode/parse
# dependency local to this cassette-inspection helper.
from notebooklm._research_task_parser import parse_research_task_models
from notebooklm.rpc import decode_response
assert CASSETTE_PATH.exists(), (
f"cassette missing: {CASSETTE_PATH}. "
"Re-record with NOTEBOOKLM_VCR_RECORD=1 — see module docstring."
)
with CASSETTE_PATH.open(encoding="utf-8") as fh:
cassette = yaml.safe_load(fh)
# Extract the rpcids query param from every batchexecute interaction
# in the order they were recorded, tracking the last POLL_RESEARCH
# interaction so we can decode its response body.
from urllib.parse import parse_qs, urlparse
rpcids_sequence: list[str] = []
last_poll_body: str = ""
for interaction in cassette.get("interactions", []):
uri = interaction.get("request", {}).get("uri", "")
if "/batchexecute" not in uri:
continue
qs = parse_qs(urlparse(uri).query)
for rpc_id in qs.get("rpcids", []):
rpcids_sequence.append(rpc_id)
if rpc_id == RPCMethod.POLL_RESEARCH.value:
body = interaction.get("response", {}).get("body", {})
string = body.get("string", "")
if isinstance(string, bytes):
string = string.decode("utf-8", errors="replace")
last_poll_body = string
poll_count = rpcids_sequence.count(RPCMethod.POLL_RESEARCH.value)
assert poll_count >= MIN_POLL_INTERACTIONS, (
f"Cassette only has {poll_count} POLL_RESEARCH interactions; "
f"need at least {MIN_POLL_INTERACTIONS} to capture the poll-to-"
"completion lifecycle. Re-record with NOTEBOOKLM_VCR_RECORD=1."
)
# Sanity: the cassette MUST include at least one START_DEEP_RESEARCH
# so the lifecycle starts where we expect it to. We use ``>= 1`` rather
# than ``== 1`` because the live API occasionally returns ReadTimeout
# on the initial kickoff and the core's transient-error retry loop
# records each attempt as its own interaction.
assert rpcids_sequence.count(RPCMethod.START_DEEP_RESEARCH.value) >= 1, (
f"Expected at least 1 START_DEEP_RESEARCH, found "
f"{rpcids_sequence.count(RPCMethod.START_DEEP_RESEARCH.value)}"
)
# The final POLL_RESEARCH response must decode to a COMPLETED research
# task — the terminal state the prior scoped-down cassette could never
# reach. Decode through the real pipeline and assert on the parsed
# ResearchStatus (enum membership, not a brittle string grep).
assert last_poll_body, "no POLL_RESEARCH interaction found in cassette"
result = decode_response(last_poll_body, RPCMethod.POLL_RESEARCH.value)
tasks = parse_research_task_models(result)
assert tasks, "final POLL_RESEARCH decoded to zero research tasks"
final_status = tasks[0].status
assert final_status == ResearchStatus.COMPLETED, (
f"final POLL_RESEARCH decodes to status {final_status!r}, not "
f"COMPLETED; the cassette does not reach the completed terminal "
"state. Re-record with NOTEBOOKLM_VCR_RECORD=1."
)
@pytest.mark.allow_no_vcr
def test_cassette_under_size_cap() -> None:
"""The cassette must stay under the 5 MB cap.
The cassette is explicitly capped at 5 MB. If a full-lifecycle recording
grows past that, trim redundant middle ``in_progress`` polls by hand —
keep START_DEEP_RESEARCH + the first couple no_research / in_progress
polls + the completed poll + the create / add_text / delete lifecycle (see
the module docstring "Trimming" section). This assertion MUST pass once
mitigations are applied — keeping it green is the gate that enforces the
cap on future re-records.
"""
if not CASSETTE_PATH.exists():
pytest.skip(f"Cassette not present at {CASSETTE_PATH}; nothing to size-check.")
size_bytes = CASSETTE_PATH.stat().st_size
size_mb = size_bytes / (1024 * 1024)
# Use ``< 5.0`` rather than ``<= 5.0`` so a re-record that creeps to
# exactly 5 MB also fails — the cap is intentionally a hard ceiling.
assert size_mb < 5.0, (
f"Cassette {CASSETTE_PATH.name} is {size_mb:.2f} MB, over the 5 MB "
"cap. Trim redundant middle in_progress polls (see module docstring "
"'Trimming') and re-verify replay."
)