Files
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

910 lines
36 KiB
Python

"""Characterization tests for private streamed-chat protocol helpers."""
from __future__ import annotations
import ast
import builtins
import importlib
import importlib.util
import inspect
import json
import logging
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
import pytest
from notebooklm._chat.wire import (
StreamingChatParseResult,
build_streaming_chat_request,
collect_texts_from_nested,
extract_answer_and_refs_from_chunk,
extract_answer_range,
extract_score,
extract_text_passages,
extract_uuid_from_nested,
parse_citations,
parse_single_citation,
parse_streaming_chat_response,
raise_if_rate_limited,
)
from notebooklm.exceptions import ChatError, UnknownRPCMethodError
from notebooklm.rpc.types import get_query_url
SRC_ROOT = Path(__file__).resolve().parents[2] / "src" / "notebooklm"
def _snapshot(
*,
csrf_token: str = "csrf",
session_id: str = "sid",
authuser: int = 0,
account_email: str | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
csrf_token=csrf_token,
session_id=session_id,
authuser=authuser,
account_email=account_email,
)
def _decode_body(body: str) -> tuple[list[Any], dict[str, list[str]]]:
body_qs = parse_qs(body, keep_blank_values=True)
f_req = json.loads(unquote(body_qs["f.req"][0]))
params = json.loads(f_req[1])
return params, body_qs
def _chunk(
text: str,
*,
marked: bool = True,
conversation_id: str | None = None,
citations: list[Any] | None = None,
) -> str:
marker = 1 if marked else 0
type_info = [[], None, None, citations or [], marker]
conv = [conversation_id, 123] if conversation_id is not None else None
inner_json = json.dumps([[text, None, conv, None, type_info]])
return json.dumps([["wrb.fr", None, inner_json]])
def _length_prefixed(*chunks: str, xssi: bool = True) -> str:
parts = [")]}'"] if xssi else []
for chunk in chunks:
parts.append(f"\n{len(chunk)}\n{chunk}")
parts.append("\n")
return "".join(parts)
def _citation(
*,
source_id: str,
chunk_id: str = "chunk-1",
text: str = "cited passage",
start: int = 10,
end: int = 20,
score: float | None = 0.9,
answer_start: int | None = None,
answer_end: int | None = None,
) -> list[Any]:
return [
[chunk_id],
[
None,
None,
score,
[[None, answer_start, answer_end]] if answer_start is not None else [[None]],
[[[start, end, [[[start, end, text]]]]]],
[[[source_id]]],
[chunk_id],
],
]
def test_module_signatures_are_stable() -> None:
signatures = {
"build_streaming_chat_request": inspect.signature(build_streaming_chat_request),
"parse_streaming_chat_response": inspect.signature(parse_streaming_chat_response),
"extract_answer_and_refs_from_chunk": inspect.signature(extract_answer_and_refs_from_chunk),
"raise_if_rate_limited": inspect.signature(raise_if_rate_limited),
"parse_citations": inspect.signature(parse_citations),
"parse_single_citation": inspect.signature(parse_single_citation),
"extract_text_passages": inspect.signature(extract_text_passages),
"extract_answer_range": inspect.signature(extract_answer_range),
"extract_score": inspect.signature(extract_score),
"collect_texts_from_nested": inspect.signature(collect_texts_from_nested),
"extract_uuid_from_nested": inspect.signature(extract_uuid_from_nested),
}
assert list(signatures["build_streaming_chat_request"].parameters) == [
"snapshot",
"notebook_id",
"question",
"source_ids",
"conversation_history",
"conversation_id",
"reqid",
]
assert signatures["build_streaming_chat_request"].parameters["snapshot"].kind is (
inspect.Parameter.KEYWORD_ONLY
)
assert list(signatures["parse_streaming_chat_response"].parameters) == ["response_text"]
assert list(signatures["extract_answer_and_refs_from_chunk"].parameters) == ["json_str"]
assert list(signatures["raise_if_rate_limited"].parameters) == ["error_payload"]
assert list(signatures["parse_citations"].parameters) == ["first"]
assert list(signatures["parse_single_citation"].parameters) == ["cite"]
assert list(signatures["extract_text_passages"].parameters) == ["cite_inner"]
assert list(signatures["extract_answer_range"].parameters) == ["cite_inner"]
assert list(signatures["extract_score"].parameters) == ["cite_inner"]
assert list(signatures["collect_texts_from_nested"].parameters) == ["nested", "texts"]
assert list(signatures["extract_uuid_from_nested"].parameters) == ["data", "max_depth"]
assert signatures["extract_uuid_from_nested"].parameters["max_depth"].default == 10
assert StreamingChatParseResult("a", [], None).answer == "a"
def test_build_request_preserves_url_body_and_param_invariants(monkeypatch) -> None:
monkeypatch.setenv("NOTEBOOKLM_BL", "boq_labs-custom_99999999.00_p0")
monkeypatch.setenv("NOTEBOOKLM_HL", "ja")
url, body, extra_headers = build_streaming_chat_request(
snapshot=_snapshot(account_email="me@example.com", authuser=5),
notebook_id="nb-123",
question="Q?",
source_ids=["s1", "s2"],
conversation_history=[["previous answer", None, 2], ["previous question", None, 1]],
conversation_id="conv-1",
reqid=234567,
)
query = parse_qs(urlparse(url).query, keep_blank_values=True)
params, body_qs = _decode_body(body)
assert url.startswith(f"{get_query_url()}?")
assert query["bl"] == ["boq_labs-custom_99999999.00_p0"]
assert query["hl"] == ["ja"]
assert query["_reqid"] == ["234567"]
assert query["rt"] == ["c"]
assert query["f.sid"] == ["sid"]
assert query["authuser"] == ["me@example.com"]
assert body_qs["at"] == ["csrf"]
assert body.endswith("&")
assert extra_headers == {}
assert params[0] == [[["s1"]], [["s2"]]]
assert len(params) == 9
assert params[7] == "nb-123"
def test_build_request_omits_default_authuser_and_blank_csrf() -> None:
url, body, _ = build_streaming_chat_request(
snapshot=_snapshot(csrf_token="", authuser=0, account_email=None),
notebook_id="nb-123",
question="Q?",
source_ids=["s1"],
conversation_history=None,
conversation_id="conv-1",
reqid=1,
)
query = parse_qs(urlparse(url).query, keep_blank_values=True)
_, body_qs = _decode_body(body)
assert "authuser" not in query
assert "at" not in body_qs
def test_build_request_uses_authuser_index_when_email_absent() -> None:
url, _, _ = build_streaming_chat_request(
snapshot=_snapshot(authuser=3, account_email=None),
notebook_id="nb-123",
question="Q?",
source_ids=["s1"],
conversation_history=None,
conversation_id="conv-1",
reqid=1,
)
assert parse_qs(urlparse(url).query)["authuser"] == ["3"]
def test_build_request_sends_null_conversation_id_for_new_conversations() -> None:
"""Regression for issue #659.
New-conversation asks must send JSON ``null`` in ``params[4]`` so the
server assigns a conversation_id that is visible in the web UI's
conversation list. The previous behavior generated ``uuid.uuid4()``
client-side and orphaned the conversation from the UI.
"""
_, body, _ = build_streaming_chat_request(
snapshot=_snapshot(),
notebook_id="nb-123",
question="Q?",
source_ids=["s1"],
conversation_history=None,
conversation_id=None,
reqid=1,
)
params, _ = _decode_body(body)
assert params[4] is None, (
"params[4] must be null for new conversations so the server assigns "
f"the conversation_id; got {params[4]!r}"
)
# Notebook id still pinned to slot 7 — the fix only touches slot 4.
assert params[7] == "nb-123"
def test_build_request_passes_through_caller_conversation_id_for_follow_ups() -> None:
"""Follow-ups must forward the caller-supplied conversation_id verbatim."""
_, body, _ = build_streaming_chat_request(
snapshot=_snapshot(),
notebook_id="nb-123",
question="Q?",
source_ids=["s1"],
conversation_history=[["prior answer", None, 2], ["prior question", None, 1]],
conversation_id="caller-supplied-conv",
reqid=1,
)
params, _ = _decode_body(body)
assert params[4] == "caller-supplied-conv"
def test_parse_response_handles_xssi_length_prefix_raw_json_and_server_conversation_id() -> None:
first = _chunk("First answer.", conversation_id="server-conv")
second = _chunk("Raw JSON answer.", conversation_id="server-conv-2")
response = _length_prefixed(first) + second
result = parse_streaming_chat_response(response)
assert result.answer == "Raw JSON answer."
assert result.references == []
assert result.conversation_id == "server-conv-2"
def test_xssi_prefix_strip_matches_shared_helper_on_real_wire_format() -> None:
"""The chat parser delegates anti-XSSI stripping to ``strip_anti_xssi``.
On the real chat wire format the ``)]}'`` prefix is always followed by a
newline, so routing through the shared stripper yields the same parsed
answer whether or not the prefix is present (regression guard for the
duplicate-stripper consolidation in issue #1205).
"""
chunk = _chunk("Prefixed answer.", conversation_id="conv")
with_prefix = parse_streaming_chat_response(_length_prefixed(chunk, xssi=True))
without_prefix = parse_streaming_chat_response(_length_prefixed(chunk, xssi=False))
assert with_prefix.answer == without_prefix.answer == "Prefixed answer."
assert with_prefix.conversation_id == without_prefix.conversation_id == "conv"
def test_chat_parser_uses_shared_strip_anti_xssi(monkeypatch) -> None:
"""``parse_streaming_chat_response`` calls the shared ``strip_anti_xssi``."""
import notebooklm._chat.wire as chat_wire
seen: list[str] = []
real_strip = chat_wire.strip_anti_xssi
def _spy(response: str) -> str:
seen.append(response)
return real_strip(response)
monkeypatch.setattr(chat_wire, "strip_anti_xssi", _spy)
response = _length_prefixed(_chunk("Answer.", conversation_id="conv"))
result = parse_streaming_chat_response(response)
assert result.answer == "Answer."
assert seen == [response]
def test_marked_answer_beats_longer_unmarked_text() -> None:
marked = _chunk("Marked.", marked=True)
unmarked = _chunk("This unmarked text is longer than the answer marker.", marked=False)
result = parse_streaming_chat_response(_length_prefixed(unmarked, marked))
assert result.answer == "Marked."
def test_unmarked_fallback_logs_under_chat_logger(caplog) -> None:
response = _length_prefixed(_chunk("Only unmarked answer.", marked=False))
with caplog.at_level(logging.WARNING, logger="notebooklm._chat"):
result = parse_streaming_chat_response(response)
assert result.answer == "Only unmarked answer."
assert any(
record.name == "notebooklm._chat" and "No marked answer found" in record.message
for record in caplog.records
)
def test_empty_response_raises_chat_response_parse_error() -> None:
"""Empty response body → zero parseable ``wrb.fr`` envelopes → raise.
This pins the contract: an empty body is wire-protocol drift / a
failed RPC, NOT a legitimate empty answer. The legitimate empty-answer
path (parseable chunk with empty text) is covered in
``tests/unit/test_chat.py``.
"""
from notebooklm.exceptions import ChatResponseParseError
with pytest.raises(ChatResponseParseError) as raised:
parse_streaming_chat_response("")
assert "No parseable chunks" in str(raised.value)
def test_parse_citations_extracts_multiple_references_and_assigns_numbers() -> None:
citations = [
_citation(
source_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
chunk_id="chunk-1",
text="first citation",
start=1,
end=11,
score=0.85,
answer_start=100,
answer_end=200,
),
_citation(
source_id="11111111-2222-3333-4444-555555555555",
chunk_id="chunk-2",
text="second citation",
start=12,
end=27,
score=0.7,
answer_start=200,
answer_end=350,
),
]
result = parse_streaming_chat_response(_length_prefixed(_chunk("Answer.", citations=citations)))
assert [ref.citation_number for ref in result.references] == [1, 2]
assert [ref.source_id for ref in result.references] == [
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"11111111-2222-3333-4444-555555555555",
]
assert [ref.chunk_id for ref in result.references] == ["chunk-1", "chunk-2"]
assert [ref.cited_text for ref in result.references] == ["first citation", "second citation"]
assert [(ref.start_char, ref.end_char) for ref in result.references] == [(1, 11), (12, 27)]
assert [(ref.answer_start_char, ref.answer_end_char) for ref in result.references] == [
(100, 200),
(200, 350),
]
assert [ref.score for ref in result.references] == [0.85, 0.7]
def test_extract_answer_range_handles_well_formed_and_malformed_shapes() -> None:
# Well-formed: [[None, start, end]]
assert extract_answer_range([None, None, None, [[None, 10, 20]]]) == (10, 20)
# Zero-length but valid: end == start
assert extract_answer_range([None, None, None, [[None, 5, 5]]]) == (5, 5)
# Missing outer: too short
assert extract_answer_range([None, None, None]) == (None, None)
# Inner [None] only (server omitted positions)
assert extract_answer_range([None, None, None, [[None]]]) == (None, None)
# Non-int positions
assert extract_answer_range([None, None, None, [[None, "10", "20"]]]) == (None, None)
# Empty outer
assert extract_answer_range([None, None, None, []]) == (None, None)
# Outer[0] not a list
assert extract_answer_range([None, None, None, ["bad"]]) == (None, None)
# bool positions rejected (bool is int subclass in Python)
assert extract_answer_range([None, None, None, [[None, True, False]]]) == (None, None)
# Partial range: end is None — paired check returns (None, None) not (10, None)
assert extract_answer_range([None, None, None, [[None, 10, None]]]) == (None, None)
assert extract_answer_range([None, None, None, [[None, None, 20]]]) == (None, None)
# Negative start rejected
assert extract_answer_range([None, None, None, [[None, -1, 10]]]) == (None, None)
# end < start rejected
assert extract_answer_range([None, None, None, [[None, 20, 10]]]) == (None, None)
def test_extract_score_accepts_float_and_int_rejects_bool_and_out_of_range() -> None:
assert extract_score([None, None, 0.6998]) == pytest.approx(0.6998)
assert extract_score([None, None, 0.0]) == 0.0 # boundary
assert extract_score([None, None, 1.0]) == 1.0 # boundary
assert extract_score([None, None, 1]) == 1.0 # int coerces
assert extract_score([None, None, None]) is None
assert extract_score([None, None, True]) is None # bool rejected
assert extract_score([None, None, "0.5"]) is None # str rejected
assert extract_score([None, None]) is None # missing index
# Out-of-range or non-finite floats
assert extract_score([None, None, 1.5]) is None
assert extract_score([None, None, -0.1]) is None
assert extract_score([None, None, float("nan")]) is None
assert extract_score([None, None, float("inf")]) is None
assert extract_score([None, None, float("-inf")]) is None
def test_citation_absence_shapes_stay_silent(caplog) -> None:
"""Genuine absence (answer without citations) emits ZERO log records.
Pins the soft half of the #1505 absence-vs-malformed policy for the
citation path: no type block, a short type block, a ``None`` citation
slot (the routine real-traffic shape), and an empty citation list all
parse to ``[]`` with no logging at any level.
"""
absent_shapes = [
["Answer only"], # short row: no type block at all
["Answer", None, None, None], # no first[4]
["Answer", None, None, None, [1, 2, 3]], # type block too short for [3]
["Answer", None, None, None, [[], None, None, None, 1]], # slot is None
["Answer", None, None, None, [[], None, None, [], 1]], # empty citation list
]
with caplog.at_level(logging.DEBUG, logger="notebooklm._chat"):
for first in absent_shapes:
assert parse_citations(first) == []
assert [r for r in caplog.records if r.name.startswith("notebooklm")] == []
def test_citationless_answer_stream_parses_silently(caplog) -> None:
"""End-to-end absence pin: a citation-less answer stream stays soft/silent."""
with caplog.at_level(logging.DEBUG, logger="notebooklm._chat"):
result = parse_streaming_chat_response(
_length_prefixed(_chunk("Answer without citations."))
)
assert result.answer == "Answer without citations."
assert result.references == []
assert [r for r in caplog.records if r.name.startswith("notebooklm")] == []
def test_citation_container_truthy_non_list_raises() -> None:
"""PRESENT-BUT-MALFORMED container: a truthy non-list at ``first[4][3]`` raises.
Documented choice (raise, not warn): the surrounding parser already treats
equivalent container drift as a raise — ``_extract_chunk_with_parseable``
raises ``UnknownRPCMethodError`` for a non-list ``inner_data[0]``, and the
#1505 ``unwrap_conversation_turns`` raises for a truthy non-list turn
container — so the citation container follows the same precedent.
"""
first = ["Answer", None, None, None, [[], None, None, {"reshaped": True}, 1]]
with pytest.raises(UnknownRPCMethodError) as raised:
parse_citations(first)
assert raised.value.source == "ChatAnswerRow.citations"
assert raised.value.path == (4, 3)
def test_non_list_answer_row_raises_in_parse_citations() -> None:
"""A non-list answer row mirrors the stream parser's structural raise."""
with pytest.raises(UnknownRPCMethodError) as raised:
parse_citations("not-a-row") # type: ignore[arg-type]
assert raised.value.source == "_chat_wire.parse_citations"
def test_stream_with_reshaped_citation_container_raises() -> None:
"""Container drift inside a real stream surfaces loudly (no silent no-citations answer)."""
inner = json.dumps([["Answer.", None, None, None, [[], None, None, "drifted", 1]]])
chunk = json.dumps([["wrb.fr", None, inner]])
with pytest.raises(UnknownRPCMethodError):
parse_streaming_chat_response(_length_prefixed(chunk))
def test_malformed_citation_rows_warn_and_keep_survivors(caplog) -> None:
"""Per-row malformation warns (at least once per row) and keeps survivors.
The rows crafted here each emit exactly one warning. A deep malformed
source-id tree could additionally trip the UUID max-recursion warning,
so the production contract is "at least one bounded warning per
malformed row" rather than exactly one.
"""
good_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
class _ExplodingLen(list):
"""A list whose ``len()`` raises — exercises the defensive per-row except."""
def __len__(self) -> int:
raise TypeError("boom")
bad_rows: list = [
["present-but-too-short"], # no detail slot -> unusable
[["chunk-x"], "detail-not-a-list"], # wrong type at the detail slot
_citation(source_id="not-a-uuid"), # no extractable source id
_ExplodingLen(), # unexpected error while decoding the row
]
first = [
"Answer",
None,
None,
None,
[[], None, None, [*bad_rows, _citation(source_id=good_id)], 1],
]
with caplog.at_level(logging.WARNING, logger="notebooklm._chat"):
refs = parse_citations(first)
assert [ref.source_id for ref in refs] == [good_id]
# The survivor keeps its RAW wire ordinal (position 5, after 4 skipped
# rows) — not a dense re-count — so the answer's [5] marker still
# resolves to it and [1]-[4] resolve to nothing.
assert [ref.citation_number for ref in refs] == [5]
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == len(bad_rows) # these rows: exactly one each
assert all("citation" in r.message for r in warnings)
def test_skipped_citation_row_leaves_numbering_hole_for_markers(caplog) -> None:
"""Regression: raw rows [good#1, bad#2, good#3] yield citation numbers {1, 3}.
Dense renumbering after a skip would re-anchor the answer's literal
``[2]`` marker onto raw citation #3 (save-as-note would anchor the WRONG
chunk via the positional fallback). Survivors must keep raw ordinals so
the skipped row leaves a hole: marker ``[2]`` resolves to ``None`` and
its anchor is dropped — never mis-anchored.
"""
from notebooklm._chat.notes import _resolve_reference
good_1 = _citation(source_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", chunk_id="chunk-1")
bad_2 = ["present-but-unusable"]
good_3 = _citation(source_id="11111111-2222-3333-4444-555555555555", chunk_id="chunk-3")
first = ["Answer [1][2][3].", None, None, None, [[], None, None, [good_1, bad_2, good_3], 1]]
with caplog.at_level(logging.WARNING, logger="notebooklm._chat"):
refs = parse_citations(first)
assert [ref.citation_number for ref in refs] == [1, 3]
# End-to-end: the stream parser's final dense fill only touches None
# numbers, so the hole survives parse_streaming_chat_response.
inner = json.dumps([first])
chunk = json.dumps([["wrb.fr", None, inner]])
result = parse_streaming_chat_response(_length_prefixed(chunk))
assert [ref.citation_number for ref in result.references] == [1, 3]
# Downstream marker resolution: the hole yields None (anchor skipped),
# the surviving markers resolve to their own chunks.
resolved_1 = _resolve_reference(result.references, 1)
assert resolved_1 is not None and resolved_1.chunk_id == "chunk-1"
assert _resolve_reference(result.references, 2) is None
resolved_3 = _resolve_reference(result.references, 3)
assert resolved_3 is not None and resolved_3.chunk_id == "chunk-3"
def test_row_level_citation_helpers_keep_soft_contracts() -> None:
"""Row-level helper contracts are unchanged: unusable rows yield None/defaults."""
assert parse_single_citation(_citation(source_id="not-a-uuid")) is None
assert extract_text_passages([None, None, None, None, ["bad-passage"]]) == (
None,
None,
None,
)
texts: list[str] = []
collect_texts_from_nested([["malformed"]], texts)
assert texts == []
def test_parse_single_citation_chunk_id_absent_keeps_citation_with_none_chunk() -> None:
"""An absent/empty/non-list chunk-id block legitimately yields ``chunk_id=None``.
Regression guard for the #1389 ``chunk_block = cite[0]`` migration: the
chunk-id slot is optional, so a missing or malformed leading block must NOT
drop the citation — it keeps the reference and leaves ``chunk_id`` ``None``.
"""
source_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
base = _citation(source_id=source_id)
cite_inner = base[1]
# Empty chunk-id block: present-but-empty list -> chunk_id stays None.
empty_block = parse_single_citation([[], cite_inner])
assert empty_block is not None
assert empty_block.source_id == source_id
assert empty_block.chunk_id is None
# Non-list chunk-id block: still a valid citation, chunk_id None.
non_list_block = parse_single_citation([None, cite_inner])
assert non_list_block is not None
assert non_list_block.chunk_id is None
# Non-string leading element inside the block -> chunk_id None.
non_str_leaf = parse_single_citation([[123], cite_inner])
assert non_str_leaf is not None
assert non_str_leaf.chunk_id is None
# Sanity: the well-formed block still populates chunk_id (migration intact).
populated = parse_single_citation(base)
assert populated is not None
assert populated.chunk_id == "chunk-1"
def test_uuid_max_recursion_logs_under_chat_logger(caplog) -> None:
with caplog.at_level(logging.WARNING, logger="notebooklm._chat"):
result = extract_uuid_from_nested([["aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]], max_depth=0)
assert result is None
assert any(
record.name == "notebooklm._chat" and "Max recursion depth" in record.message
for record in caplog.records
)
def test_non_json_inner_chunk_debug_log_is_guarded_and_uses_chat_logger(caplog) -> None:
bad_chunk = json.dumps([["wrb.fr", "method_id", "{not valid json}"]])
with caplog.at_level(logging.DEBUG, logger="notebooklm._chat"):
text, is_answer, refs, conv_id = extract_answer_and_refs_from_chunk(bad_chunk)
assert (text, is_answer, refs, conv_id) == (None, False, [], None)
assert any(
record.name == "notebooklm._chat" and "Stream parser" in record.message
for record in caplog.records
)
def test_user_displayable_error_payload_raises_same_chat_error_message() -> None:
payload = [
8,
None,
[["type.googleapis.com/google.rpc.UserDisplayableError", "details"]],
]
with pytest.raises(
ChatError,
match=(
"Chat request was rate limited or rejected by the API. "
"Wait a few seconds and try again."
),
):
raise_if_rate_limited(payload)
def _wrb_envelope(inner: Any) -> str:
"""Length-prefixed single-``wrb.fr``-frame body wrapping ``inner``."""
return _length_prefixed(json.dumps([["wrb.fr", None, json.dumps(inner)]]))
@pytest.mark.parametrize(
"drifted_inner",
[
["scalar-answer-row"], # answer row is a str (indexable but wrong type)
[{"answer": "x"}], # answer row became a dict
[42], # answer row became an int
[None], # answer row became null
],
)
def test_drifted_answer_row_raises(drifted_inner: Any) -> None:
"""A populated ``wrb.fr`` record whose answer row is not a list is drift.
Strict decoding is the only mode (the ``NOTEBOOKLM_STRICT_DECODE=0``
soft-mode opt-out was retired in v0.7.0), so this raises
:class:`UnknownRPCMethodError` instead of silently collapsing to an empty
answer — that silent collapse was the gap the
``architecture-gap-review`` flagged for ``_chat.wire`` (ADR-0011:38).
"""
from notebooklm.exceptions import UnknownRPCMethodError
with pytest.raises(UnknownRPCMethodError):
parse_streaming_chat_response(_wrb_envelope(drifted_inner))
def test_empty_answer_row_is_tolerated_as_heartbeat() -> None:
"""A populated outer wrapping an empty answer row (``[[]]``) is a
degenerate/heartbeat record, NOT drift — it returns an empty answer."""
result = parse_streaming_chat_response(_wrb_envelope([[]]))
assert result.answer == ""
def test_error_frame_surfaces_chat_error_with_code() -> None:
"""An ``"er"`` error frame must surface the embedded server error.
The previous parser only inspected ``"wrb.fr"`` frames and silently
skipped ``"er"`` frames, so a server-side chat error collapsed into the
generic empty/parse-failure path. The parser now raises a
:class:`ChatError` that echoes the embedded error code.
"""
error_frame = json.dumps([["er", "GenerateFreeFormStreamed", 13, "internal boom"]])
with pytest.raises(ChatError, match=r"server returned an error frame.*code 13"):
parse_streaming_chat_response(_length_prefixed(error_frame))
def test_error_frame_without_code_still_surfaces_chat_error() -> None:
"""A short ``"er"`` frame (no code slot) still raises a ``ChatError``
rather than being silently skipped."""
error_frame = json.dumps([["er", "GenerateFreeFormStreamed"]])
with pytest.raises(ChatError, match="server returned an error frame"):
parse_streaming_chat_response(_length_prefixed(error_frame))
def test_oversized_request_rejection_surfaces_status_not_parse_error() -> None:
"""A null-inner ``wrb.fr`` with an item[5] status payload is a rejection.
The server rejects an over-long question with a ``wrb.fr`` frame carrying no
answer JSON (null item[2]) and a bare grpc-style status at item[5]
(``[3]`` == ``INVALID_ARGUMENT``), followed by ``di`` / ``af.httprm`` / ``e``
stream-bookkeeping frames. The parser used to skip the status frame and
raise the generic "No parseable chunks" error, masking the real cause
(discussion #1472). It now raises a :class:`ChatError` echoing the status.
The body below is byte-for-byte the real captured rejection from #1472 — the
``["e", 4, None, None, 132]`` trailer is deliberately included to pin that it
is NOT treated as the error (its trailing ``132`` is a response byte count,
not an error code).
"""
wire = _length_prefixed(
json.dumps([["wrb.fr", None, None, None, None, [3]]]),
json.dumps([["di", 198], ["af.httprm", 197, "-1460015816955467607", 6]]),
json.dumps([["e", 4, None, None, 132]]),
)
with pytest.raises(ChatError, match=r"rejected by the server \(status 3\)"):
parse_streaming_chat_response(wire)
def test_null_inner_frame_with_empty_payload_still_raises_without_status() -> None:
"""A null-inner ``wrb.fr`` with an empty ``[]`` payload is not swallowed.
Guards the ``is not None`` (not truthy) check: a present-but-empty item[5]
has no status to report, but the frame still carries no answer, so it must
raise a rejection rather than collapse into "No parseable chunks".
"""
wire = _length_prefixed(json.dumps([["wrb.fr", None, None, None, None, []]]))
with pytest.raises(ChatError, match=r"rejected by the server\."):
parse_streaming_chat_response(wire)
def test_e_terminator_frame_does_not_break_successful_answer() -> None:
"""A trailing ``"e"`` stream terminator must not turn a good answer into an error.
Successful streamed responses end with an ``["e", n, None, None, bytes]``
bookkeeping frame (the trailing number is a running byte count). Regression
guard for discussion #1472: the ``"e"`` tag must stay inert so a valid
answer parses normally instead of raising.
"""
body = _length_prefixed(_chunk("Real answer."), json.dumps([["e", 15, None, None, 41687]]))
result = parse_streaming_chat_response(body)
assert result.answer == "Real answer."
def test_chat_wire_static_import_guard() -> None:
forbidden = {
"notebooklm",
"notebooklm.client",
"notebooklm._chat",
"notebooklm._core",
"notebooklm.rpc.overrides",
}
tree = ast.parse((SRC_ROOT / "_chat" / "wire.py").read_text(encoding="utf-8"))
imports: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
if node.level == 1 or node.level == 2:
module = f"notebooklm.{module}" if module else "notebooklm"
imports.add(module)
for alias in node.names:
imports.add(f"{module}.{alias.name}" if module else alias.name)
violations = forbidden & imports
assert not violations, f"_chat_wire.py imported forbidden modules: {violations}"
def test_chat_wire_runtime_import_does_not_request_forbidden_modules(monkeypatch) -> None:
import notebooklm # noqa: F401
forbidden = {
"notebooklm.client",
"notebooklm._chat",
"notebooklm._core",
"notebooklm.rpc.overrides",
}
sys.modules.pop("notebooklm._chat.wire", None)
real_import = builtins.__import__
def guarded_import(
name: str,
globals_: dict[str, Any] | None = None,
locals_: dict[str, Any] | None = None,
fromlist: tuple[str, ...] = (),
level: int = 0,
) -> Any:
resolved = name
if level:
package = globals_.get("__package__") if globals_ else None
if package:
resolved = importlib.util.resolve_name(f"{'.' * level}{name}", package)
candidates = {resolved}
if fromlist:
candidates.update(f"{resolved}.{item}" for item in fromlist)
violations = forbidden & candidates
if violations:
raise AssertionError(f"_chat_wire imported forbidden modules {violations}")
return real_import(name, globals_, locals_, fromlist, level)
monkeypatch.setattr(builtins, "__import__", guarded_import)
module = importlib.import_module("notebooklm._chat.wire")
assert module.__name__ == "notebooklm._chat.wire"
def test_chat_wire_and_chat_smoke_import_order() -> None:
for name in ("notebooklm._chat", "notebooklm._chat.wire"):
sys.modules.pop(name, None)
protocol = importlib.import_module("notebooklm._chat.wire")
chat = importlib.import_module("notebooklm._chat")
assert protocol.__name__ == "notebooklm._chat.wire"
assert chat.__name__ == "notebooklm._chat"
for name in ("notebooklm._chat", "notebooklm._chat.wire"):
sys.modules.pop(name, None)
chat = importlib.import_module("notebooklm._chat")
protocol = importlib.import_module("notebooklm._chat.wire")
assert chat.__name__ == "notebooklm._chat"
assert protocol.__name__ == "notebooklm._chat.wire"
def test_chat_module_keeps_only_delegating_stream_parser_wrappers() -> None:
tree = ast.parse((SRC_ROOT / "_chat" / "api.py").read_text(encoding="utf-8"))
wrapper_names = {
"_parse_ask_response_with_references",
"_extract_answer_and_refs_from_chunk",
"_raise_if_rate_limited",
"_parse_citations",
"_parse_single_citation",
"_extract_text_passages",
"_collect_texts_from_nested",
"_extract_uuid_from_nested",
}
expected_delegate = {
"_parse_ask_response_with_references": "parse_streaming_chat_response",
"_extract_answer_and_refs_from_chunk": "extract_answer_and_refs_from_chunk",
"_raise_if_rate_limited": "raise_if_rate_limited",
"_parse_citations": "parse_citations",
"_parse_single_citation": "parse_single_citation",
"_extract_text_passages": "extract_text_passages",
"_collect_texts_from_nested": "collect_texts_from_nested",
"_extract_uuid_from_nested": "extract_uuid_from_nested",
}
wrappers = {
node.name: node
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name in wrapper_names
}
assert set(wrappers) == wrapper_names
for name, node in wrappers.items():
constants = {child.value for child in ast.walk(node) if isinstance(child, ast.Constant)}
assert "wrb.fr" not in constants, f"{name} owns streamed wrb.fr parsing"
called_helpers = {
child.func.id
for child in ast.walk(node)
if isinstance(child, ast.Call) and isinstance(child.func, ast.Name)
}
called_helpers.update(
child.func.attr
for child in ast.walk(node)
if isinstance(child, ast.Call) and isinstance(child.func, ast.Attribute)
)
assert expected_delegate[name] in called_helpers, f"{name} does not delegate"
for child in ast.walk(node):
assert not (
isinstance(child, ast.Call)
and isinstance(child.func, ast.Attribute)
and child.func.attr == "loads"
and isinstance(child.func.value, ast.Name)
and child.func.value.id == "json"
), f"{name} owns JSON streamed chunk parsing"
assert not (
name == "_extract_uuid_from_nested"
and isinstance(child, ast.Call)
and isinstance(child.func, ast.Attribute)
and isinstance(child.func.value, ast.Name)
and child.func.value.id == "self"
and child.func.attr == "_extract_uuid_from_nested"
), "_chat.py owns local UUID recursion"