Files
teng-lin--notebooklm-py/tests/unit/mcp/test_chat.py
T
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

783 lines
34 KiB
Python

"""Unit tests for the chat MCP tools.
Drives ``chat_ask`` / ``chat_configure`` through the in-memory FastMCP ``Client``
against the mocked ``NotebookLMClient``, asserting the serialized
``structured_content``. Covers the happy path, conversation-id passthrough,
name-vs-id resolution, the configure goal/length dispatch, and error projection.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any
import pytest
# Skip cleanly when the `mcp` extra (fastmcp) is absent; see conftest.py.
pytest.importorskip("fastmcp")
from fastmcp.exceptions import ToolError # noqa: E402 - after importorskip guard
from notebooklm.exceptions import ( # noqa: E402 - after importorskip guard
ChatError,
RPCError,
)
from notebooklm.types import ( # noqa: E402 - after importorskip guard
ChatGoal,
ChatResponseLength,
ChatSettings,
)
from .conftest import AsyncMock # noqa: E402 - after importorskip guard
@dataclass
class FakeNotebook:
id: str
title: str
@dataclass
class FakeReference:
source_id: str
citation_number: int | None = None
cited_text: str | None = None
chunk_id: str | None = None
start_char: int | None = None
score: float | None = None
@dataclass
class FakeAskResult:
answer: str
conversation_id: str
turn_number: int = 1
is_follow_up: bool = False
references: list[Any] = field(default_factory=list)
raw_response: str = ""
NB_ID = "11111111-1111-1111-1111-111111111111"
CONV_ID = "conv-abc"
async def test_chat_ask(mcp_call, mock_client) -> None:
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
result = await mcp_call("chat_ask", {"notebook": NB_ID, "question": "what?"})
assert result.structured_content["notebook_id"] == NB_ID
assert result.structured_content["answer"] == "42"
assert result.structured_content["conversation_id"] == CONV_ID
mock_client.chat.ask.assert_awaited_once_with(
NB_ID, "what?", source_ids=None, conversation_id=None
)
async def test_chat_ask_passes_conversation_id(mcp_call, mock_client) -> None:
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="ok", conversation_id=CONV_ID, is_follow_up=True)
)
await mcp_call(
"chat_ask",
{"notebook": NB_ID, "question": "follow up", "conversation_id": CONV_ID},
)
mock_client.chat.ask.assert_awaited_once_with(
NB_ID, "follow up", source_ids=None, conversation_id=CONV_ID
)
async def test_chat_ask_resolves_notebook_by_name(mcp_call, mock_client) -> None:
mock_client.notebooks.list = AsyncMock(
return_value=[FakeNotebook(id=NB_ID, title="My Notebook")]
)
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="hi", conversation_id=CONV_ID)
)
result = await mcp_call("chat_ask", {"notebook": "My Notebook", "question": "q"})
# Asked by NAME → the response echoes the resolved canonical id (#1808).
assert result.structured_content["notebook_id"] == NB_ID
mock_client.chat.ask.assert_awaited_once_with(NB_ID, "q", source_ids=None, conversation_id=None)
# Full-UUID source ids take resolve_source's fast path (no listing needed).
_SRC_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_SRC_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
async def test_chat_ask_omitting_source_ids_uses_all(mcp_call, mock_client) -> None:
"""Omitting ``source_ids`` => None (=> all sources, client.chat.ask's contract)."""
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
await mcp_call("chat_ask", {"notebook": NB_ID, "question": "what?"})
assert mock_client.chat.ask.await_args.kwargs["source_ids"] is None
async def test_chat_ask_source_ids_list(mcp_call, mock_client) -> None:
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
result = await mcp_call(
"chat_ask",
{"notebook": NB_ID, "question": "what?", "source_ids": [_SRC_A, _SRC_B]},
)
assert mock_client.chat.ask.await_args.kwargs["source_ids"] == [_SRC_A, _SRC_B]
# A source-scoped call echoes the resolved canonical source_ids (#1808).
assert result.structured_content["source_ids"] == [_SRC_A, _SRC_B]
async def test_chat_ask_unscoped_omits_source_ids_echo(mcp_call, mock_client) -> None:
"""No source scope => no source_ids key in the response (all-sources contract)."""
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
result = await mcp_call("chat_ask", {"notebook": NB_ID, "question": "what?"})
assert "source_ids" not in result.structured_content
async def test_chat_ask_source_ids_json_string(mcp_call, mock_client) -> None:
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
await mcp_call(
"chat_ask",
{"notebook": NB_ID, "question": "what?", "source_ids": f'["{_SRC_A}"]'},
)
assert mock_client.chat.ask.await_args.kwargs["source_ids"] == [_SRC_A]
async def test_chat_ask_source_ids_comma_string(mcp_call, mock_client) -> None:
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
await mcp_call(
"chat_ask",
{"notebook": NB_ID, "question": "what?", "source_ids": f"{_SRC_A},{_SRC_B}"},
)
assert mock_client.chat.ask.await_args.kwargs["source_ids"] == [_SRC_A, _SRC_B]
async def test_chat_ask_source_ids_scalar_string(mcp_call, mock_client) -> None:
"""A bare scalar-string source_ids resolves/passes a single id (coerce_list)."""
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
await mcp_call("chat_ask", {"notebook": NB_ID, "question": "what?", "source_ids": _SRC_A})
assert mock_client.chat.ask.await_args.kwargs["source_ids"] == [_SRC_A]
async def test_chat_ask_empty_source_ids_uses_all(mcp_call, mock_client) -> None:
"""An explicit empty list => None (all sources), never [] (zero sources)."""
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
await mcp_call("chat_ask", {"notebook": NB_ID, "question": "what?", "source_ids": []})
assert mock_client.chat.ask.await_args.kwargs["source_ids"] is None
async def test_chat_ask_whitespace_source_ids_uses_all(mcp_call, mock_client) -> None:
"""A whitespace-only string coerces to [] => collapses to None (all sources)."""
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
await mcp_call("chat_ask", {"notebook": NB_ID, "question": "what?", "source_ids": " "})
assert mock_client.chat.ask.await_args.kwargs["source_ids"] is None
async def test_chat_ask_recall_only(mcp_call, mock_client) -> None:
"""Empty question + history>0 recalls pairs (no ask) and echoes the conversation."""
mock_client.chat.get_conversation_id = AsyncMock(return_value=CONV_ID)
mock_client.chat.get_history = AsyncMock(return_value=[("q1", "a1"), ("q2", "a2")])
mock_client.chat.ask = AsyncMock()
result = await mcp_call("chat_ask", {"notebook": NB_ID, "history": 5})
assert result.structured_content["history"] == [
{"question": "q1", "answer": "a1"},
{"question": "q2", "answer": "a2"},
]
# Recall-only echoes the resolved conversation; no answer is produced.
assert result.structured_content["notebook_id"] == NB_ID
assert result.structured_content["conversation_id"] == CONV_ID
assert "answer" not in result.structured_content
mock_client.chat.ask.assert_not_awaited()
# ``limit`` is doubled (role-rows ~2 per pair) and pinned to the resolved id.
mock_client.chat.get_history.assert_awaited_once_with(NB_ID, limit=10, conversation_id=CONV_ID)
async def test_chat_ask_recall_only_empty_conversation(mcp_call, mock_client) -> None:
"""Recall against a notebook with no conversation => empty history, no echo, no fetch."""
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
mock_client.chat.get_history = AsyncMock(return_value=[])
result = await mcp_call("chat_ask", {"notebook": NB_ID, "history": 5})
assert result.structured_content["history"] == []
assert "conversation_id" not in result.structured_content
# No conversation => skip get_history (it would just re-resolve the absent id).
mock_client.chat.get_history.assert_not_awaited()
async def test_chat_ask_history_explicit_conversation_id_skips_resolve(
mcp_call, mock_client
) -> None:
"""An explicit conversation_id with history>0 must not pay the resolve round-trip."""
mock_client.chat.get_history = AsyncMock(return_value=[("q1", "a1")])
result = await mcp_call(
"chat_ask", {"notebook": NB_ID, "history": 2, "conversation_id": CONV_ID}
)
assert result.structured_content["history"] == [{"question": "q1", "answer": "a1"}]
assert result.structured_content["conversation_id"] == CONV_ID
mock_client.chat.get_conversation_id.assert_not_called()
mock_client.chat.get_history.assert_awaited_once_with(NB_ID, limit=4, conversation_id=CONV_ID)
async def test_chat_ask_question_with_history_no_conversation(mcp_call, mock_client) -> None:
"""Question + history>0 with no prior conversation: fresh ask, empty history, no fetch."""
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
mock_client.chat.get_history = AsyncMock()
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
result = await mcp_call("chat_ask", {"notebook": NB_ID, "question": "q", "history": 3})
assert result.structured_content["answer"] == "42"
assert result.structured_content["history"] == []
mock_client.chat.get_history.assert_not_awaited()
# No conversation resolved => the ask starts a fresh one.
assert mock_client.chat.ask.await_args.kwargs["conversation_id"] is None
async def test_chat_ask_negative_history_rejected(mcp_call, mock_client) -> None:
"""A negative history (even with a question) is invalid input, not a silent ask."""
mock_client.chat.ask = AsyncMock()
with pytest.raises(ToolError):
await mcp_call("chat_ask", {"notebook": NB_ID, "question": "q", "history": -1})
mock_client.chat.ask.assert_not_awaited()
async def test_chat_ask_with_history_includes_both(mcp_call, mock_client) -> None:
"""Question + history>0 returns the answer and prior pairs, pinned to one id."""
mock_client.chat.get_conversation_id = AsyncMock(return_value=CONV_ID)
mock_client.chat.get_history = AsyncMock(return_value=[("q1", "a1")])
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
result = await mcp_call("chat_ask", {"notebook": NB_ID, "question": "what?", "history": 3})
assert result.structured_content["answer"] == "42"
assert result.structured_content["history"] == [{"question": "q1", "answer": "a1"}]
# History fetched with doubled limit, and both reads/writes pin the same id.
mock_client.chat.get_history.assert_awaited_once_with(NB_ID, limit=6, conversation_id=CONV_ID)
assert mock_client.chat.ask.await_args.kwargs["conversation_id"] == CONV_ID
async def test_chat_ask_zero_history_skips_recall(mcp_call, mock_client) -> None:
"""A plain ask (history defaults to 0) never touches the history/resolve path."""
mock_client.chat.get_history = AsyncMock()
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="x", conversation_id=CONV_ID)
)
result = await mcp_call("chat_ask", {"notebook": NB_ID, "question": "q"})
assert "history" not in result.structured_content
mock_client.chat.get_history.assert_not_awaited()
# The pure-ask hot path must not pay an extra get_conversation_id round-trip.
mock_client.chat.get_conversation_id.assert_not_called()
async def test_chat_ask_requires_question_or_history(mcp_call, mock_client) -> None:
"""Neither a question nor history>0 is a validation error, not a silent no-op."""
with pytest.raises(ToolError):
await mcp_call("chat_ask", {"notebook": NB_ID})
async def test_chat_ask_whitespace_question_rejected(mcp_call, mock_client) -> None:
"""A whitespace-only question with no history is rejected, not sent as a blank ask."""
mock_client.chat.ask = AsyncMock()
with pytest.raises(ToolError):
await mcp_call("chat_ask", {"notebook": NB_ID, "question": " "})
mock_client.chat.ask.assert_not_awaited()
@dataclass
class FakePromptSuggestion:
title: str
prompt: str
async def test_chat_ask_default_no_suggest_followups(mcp_call, mock_client) -> None:
"""Default chat_ask omits suggested_prompts and never calls suggest_prompts."""
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
mock_client.notebooks.suggest_prompts = AsyncMock()
result = await mcp_call("chat_ask", {"notebook": NB_ID, "question": "what?"})
assert "suggested_prompts" not in result.structured_content
mock_client.notebooks.suggest_prompts.assert_not_called()
async def test_chat_ask_suggest_followups_with_question(mcp_call, mock_client) -> None:
"""suggest_followups=True with a question returns the answer AND suggestions."""
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
mock_client.notebooks.suggest_prompts = AsyncMock(
return_value=[
FakePromptSuggestion(title="T1", prompt="P1"),
FakePromptSuggestion(title="T2", prompt="P2"),
]
)
result = await mcp_call(
"chat_ask", {"notebook": NB_ID, "question": "what?", "suggest_followups": True}
)
sc = result.structured_content
assert sc["answer"] == "42"
assert sc["suggested_prompts"] == [
{"title": "T1", "prompt": "P1"},
{"title": "T2", "prompt": "P2"},
]
# Keyword-only args (positional would TypeError against the real signature).
mock_client.notebooks.suggest_prompts.assert_awaited_once_with(
NB_ID, source_ids=None, mode=4, query="what?"
)
async def test_chat_ask_suggest_only_no_question(mcp_call, mock_client) -> None:
"""No question + suggest_followups=True returns suggestions without raising."""
mock_client.chat.ask = AsyncMock()
mock_client.notebooks.suggest_prompts = AsyncMock(
return_value=[FakePromptSuggestion(title="T", prompt="P")]
)
result = await mcp_call("chat_ask", {"notebook": NB_ID, "suggest_followups": True})
sc = result.structured_content
assert sc["suggested_prompts"] == [{"title": "T", "prompt": "P"}]
assert "answer" not in sc
# No question => no ask, and the suggest query is unsteered (None).
mock_client.chat.ask.assert_not_awaited()
mock_client.notebooks.suggest_prompts.assert_awaited_once_with(
NB_ID, source_ids=None, mode=4, query=None
)
async def test_chat_ask_all_three_absent_still_rejected(mcp_call, mock_client) -> None:
"""No question, history=0, suggest_followups=False remains a validation error."""
mock_client.notebooks.suggest_prompts = AsyncMock()
with pytest.raises(ToolError):
await mcp_call("chat_ask", {"notebook": NB_ID, "suggest_followups": False})
mock_client.notebooks.suggest_prompts.assert_not_called()
async def test_chat_ask_cancels_sibling_on_error(mcp_call, mock_client) -> None:
"""When ask + suggest run concurrently (question + suggest_followups) and one
raises, the still-running sibling is cancelled + drained (no leaked coroutine)
and the error propagates as ToolError (#1760)."""
sibling_cancelled = asyncio.Event()
async def _slow_ask(*_a: Any, **_k: Any) -> Any:
try:
await asyncio.sleep(30) # the slow sibling — should be cancelled
except asyncio.CancelledError:
sibling_cancelled.set()
raise
# never reached (cancelled first); return a real fake, not Ellipsis
return FakeAskResult(answer="unused", conversation_id=CONV_ID) # pragma: no cover
async def _raise_suggest(*_a: Any, **_k: Any) -> Any:
await asyncio.sleep(0) # let the slow sibling start first
raise RPCError("unexpected boom")
mock_client.chat.ask = _slow_ask
mock_client.notebooks.suggest_prompts = _raise_suggest
with pytest.raises(ToolError):
await mcp_call(
"chat_ask",
{"notebook": NB_ID, "question": "what?", "suggest_followups": True},
)
assert sibling_cancelled.is_set(), "slow sibling read was not cancelled/drained"
@dataclass
class FakeSource:
id: str
title: str | None
async def test_chat_ask_two_title_refs_list_once_order_preserved(mcp_call, mock_client) -> None:
"""Two non-UUID refs resolve via a single ``sources.list`` snapshot, in input order."""
mock_client.sources.list = AsyncMock(
return_value=[
FakeSource(id=_SRC_A, title="Alpha"),
FakeSource(id=_SRC_B, title="Beta"),
]
)
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
await mcp_call(
"chat_ask",
{"notebook": NB_ID, "question": "what?", "source_ids": ["Beta", "Alpha"]},
)
mock_client.sources.list.assert_awaited_once_with(NB_ID)
assert mock_client.chat.ask.await_args.kwargs["source_ids"] == [_SRC_B, _SRC_A]
async def test_chat_ask_suggest_followups_resolves_source_ids(mcp_call, mock_client) -> None:
"""suggest_prompts is called with resolved source ids, not the raw title."""
mock_client.sources.list = AsyncMock(return_value=[FakeSource(id=_SRC_A, title="Alpha")])
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID)
)
mock_client.notebooks.suggest_prompts = AsyncMock(
return_value=[FakePromptSuggestion(title="T", prompt="P")]
)
await mcp_call(
"chat_ask",
{
"notebook": NB_ID,
"question": "what?",
"source_ids": "Alpha",
"suggest_followups": True,
},
)
assert mock_client.notebooks.suggest_prompts.await_args.kwargs["source_ids"] == [_SRC_A]
# The ask path shares the same resolved ids (resolution happens once, up front).
assert mock_client.chat.ask.await_args.kwargs["source_ids"] == [_SRC_A]
# Resolve-once: a single sources.list snapshot feeds both the ask + suggest paths.
mock_client.sources.list.assert_awaited_once_with(NB_ID)
async def test_chat_ask_recall_only_ignores_source_ids(mcp_call, mock_client) -> None:
"""A recall-only turn (history>0, no question, no suggest) does NOT resolve
source_ids — no sources.list round-trip, and a stale ref can't fail the recall."""
mock_client.sources.list = AsyncMock(return_value=[])
mock_client.chat.get_conversation_id = AsyncMock(return_value=CONV_ID)
mock_client.chat.get_history = AsyncMock(return_value=[("q", "a")])
result = await mcp_call(
"chat_ask",
{"notebook": NB_ID, "history": 1, "source_ids": "Nonexistent-Title"},
)
assert result.structured_content["history"] == [{"question": "q", "answer": "a"}]
mock_client.sources.list.assert_not_called() # source refs ignored in pure recall
async def test_chat_configure_goal_and_length(mcp_call, mock_client) -> None:
mock_client.chat.configure = AsyncMock(return_value=None)
result = await mcp_call(
"chat_configure",
{"notebook": NB_ID, "goal": "Explain like I'm five", "response_length": "longer"},
)
sc = result.structured_content
assert sc["notebook_id"] == NB_ID
assert sc["persona"] == "Explain like I'm five"
assert sc["response_length"] == "longer"
assert sc["goal_name"] == "custom"
mock_client.chat.configure.assert_awaited_once()
async def test_chat_configure_empty_rejected(mcp_call, mock_client) -> None:
mock_client.chat.configure = AsyncMock(return_value=None)
with pytest.raises(ToolError) as excinfo:
await mcp_call("chat_configure", {"notebook": NB_ID})
assert "at least one setting" in str(excinfo.value)
mock_client.chat.configure.assert_not_called()
async def test_chat_configure_goal_only_merges(mcp_call, mock_client) -> None:
"""goal-only is a partial custom write: it now MERGES (preserves length), not rejected (#1751)."""
mock_client.chat.configure = AsyncMock(return_value=None)
mock_client.chat.get_settings = AsyncMock(
return_value=ChatSettings(
goal=ChatGoal.DEFAULT, response_length=ChatResponseLength.SHORTER, custom_prompt=None
)
)
result = await mcp_call("chat_configure", {"notebook": NB_ID, "goal": "tutor"})
sc = result.structured_content
assert sc["persona"] == "tutor"
assert sc["goal_name"] == "custom"
# The omitted response_length is preserved from the current settings (SHORTER).
mock_client.chat.configure.assert_awaited_once_with(
NB_ID,
goal=ChatGoal.CUSTOM,
response_length=ChatResponseLength.SHORTER,
custom_prompt="tutor",
)
async def test_chat_configure_length_only_merges(mcp_call, mock_client) -> None:
"""length-only is a partial custom write: it now MERGES (preserves goal+persona) (#1751)."""
mock_client.chat.configure = AsyncMock(return_value=None)
mock_client.chat.get_settings = AsyncMock(
return_value=ChatSettings(
goal=ChatGoal.CUSTOM,
response_length=ChatResponseLength.DEFAULT,
custom_prompt="keep me",
)
)
result = await mcp_call("chat_configure", {"notebook": NB_ID, "response_length": "longer"})
sc = result.structured_content
# Delta reporting: only the field this call set is echoed.
assert sc["response_length"] == "longer"
assert sc["persona"] is None
# The omitted goal/persona is preserved from the current settings.
mock_client.chat.configure.assert_awaited_once_with(
NB_ID,
goal=ChatGoal.CUSTOM,
response_length=ChatResponseLength.LONGER,
custom_prompt="keep me",
)
async def test_chat_configure_length_default_only_merges(mcp_call, mock_client) -> None:
"""response_length="default" alone is an explicit setting → partial merge, not rejected."""
mock_client.chat.configure = AsyncMock(return_value=None)
mock_client.chat.get_settings = AsyncMock(
return_value=ChatSettings(
goal=ChatGoal.LEARNING_GUIDE,
response_length=ChatResponseLength.LONGER,
custom_prompt=None,
)
)
await mcp_call("chat_configure", {"notebook": NB_ID, "response_length": "default"})
mock_client.chat.configure.assert_awaited_once_with(
NB_ID,
goal=ChatGoal.LEARNING_GUIDE,
response_length=ChatResponseLength.DEFAULT,
custom_prompt=None,
)
async def test_chat_configure_empty_goal_only_rejected(mcp_call, mock_client) -> None:
"""An empty goal is not-supplied, so goal: "" with no length is a bare/empty call."""
mock_client.chat.configure = AsyncMock(return_value=None)
with pytest.raises(ToolError) as excinfo:
await mcp_call("chat_configure", {"notebook": NB_ID, "goal": ""})
assert "at least one setting" in str(excinfo.value)
mock_client.chat.configure.assert_not_called()
async def test_chat_configure_empty_goal_with_length_merges(mcp_call, mock_client) -> None:
"""goal: "" + a length is a length-ONLY (partial) write, so it merges (#1751).
Pins ``bool(goal)`` (not ``goal is not None``): an empty goal counts as
not-supplied, so pairing it with a response_length is a length-only partial
that preserves the current goal/persona.
"""
mock_client.chat.configure = AsyncMock(return_value=None)
mock_client.chat.get_settings = AsyncMock(
return_value=ChatSettings(
goal=ChatGoal.DEFAULT, response_length=ChatResponseLength.DEFAULT, custom_prompt=None
)
)
await mcp_call("chat_configure", {"notebook": NB_ID, "goal": "", "response_length": "longer"})
mock_client.chat.configure.assert_awaited_once_with(
NB_ID, goal=ChatGoal.DEFAULT, response_length=ChatResponseLength.LONGER, custom_prompt=None
)
async def test_chat_configure_bare_rejects_before_resolving_notebook(mcp_call, mock_client) -> None:
"""The surviving BARE-call guard runs BEFORE resolve_notebook: it never lists.
``NB_ID`` is a full UUID that fast-paths without a ``notebooks.list`` round-trip,
so a bare call *by title* is what proves ordering. A title forces resolution to
call ``client.notebooks.list`` — asserting it stays uncalled pins that the
fail-loud bare-call guard short-circuits ahead of any network work. (A partial
custom call is no longer rejected — it resolves the notebook and merges.)
"""
mock_client.chat.configure = AsyncMock(return_value=None)
mock_client.notebooks.list = AsyncMock(
return_value=[FakeNotebook(id=NB_ID, title="My Notebook")]
)
with pytest.raises(ToolError):
await mcp_call("chat_configure", {"notebook": "My Notebook"})
mock_client.notebooks.list.assert_not_called()
mock_client.chat.configure.assert_not_called()
async def test_chat_configure_mode_applies_preset(mcp_call, mock_client) -> None:
"""A chat_mode selects the predefined preset via set_mode (not the custom branch)."""
mock_client.chat.set_mode = AsyncMock(return_value=None)
mock_client.chat.configure = AsyncMock(return_value=None)
result = await mcp_call("chat_configure", {"notebook": NB_ID, "chat_mode": "learning-guide"})
sc = result.structured_content
assert sc["notebook_id"] == NB_ID
assert sc["mode"] == "learning-guide"
assert sc["persona"] is None and sc["response_length"] is None
mock_client.chat.set_mode.assert_awaited_once()
# The preset path must not also write the custom settings block.
mock_client.chat.configure.assert_not_called()
async def test_chat_configure_mode_rejects_goal_combination(mcp_call, mock_client) -> None:
"""chat_mode + a (truthy) goal is rejected, not silently dropped."""
mock_client.chat.set_mode = AsyncMock(return_value=None)
mock_client.chat.configure = AsyncMock(return_value=None)
with pytest.raises(ToolError) as excinfo:
await mcp_call("chat_configure", {"notebook": NB_ID, "chat_mode": "concise", "goal": "x"})
assert "chat_mode" in str(excinfo.value)
mock_client.chat.set_mode.assert_not_called()
mock_client.chat.configure.assert_not_called()
async def test_chat_configure_mode_rejects_response_length_combination(
mcp_call, mock_client
) -> None:
"""chat_mode + response_length (a real setting, incl. 'default') is rejected."""
mock_client.chat.set_mode = AsyncMock(return_value=None)
mock_client.chat.configure = AsyncMock(return_value=None)
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"chat_configure",
{"notebook": NB_ID, "chat_mode": "detailed", "response_length": "longer"},
)
assert "chat_mode" in str(excinfo.value)
mock_client.chat.set_mode.assert_not_called()
mock_client.chat.configure.assert_not_called()
async def test_chat_configure_mode_with_empty_goal_ok(mcp_call, mock_client) -> None:
"""An empty goal ("") is a no-op, so it does NOT block a chat_mode preset."""
mock_client.chat.set_mode = AsyncMock(return_value=None)
result = await mcp_call(
"chat_configure", {"notebook": NB_ID, "chat_mode": "concise", "goal": ""}
)
assert result.structured_content["mode"] == "concise"
mock_client.chat.set_mode.assert_awaited_once()
async def test_chat_configure_rejects_bad_mode(mcp_call, mock_client) -> None:
"""An out-of-enum chat_mode is rejected at the Literal schema boundary, no RPC."""
mock_client.chat.set_mode = AsyncMock(return_value=None)
with pytest.raises(ToolError):
await mcp_call("chat_configure", {"notebook": NB_ID, "chat_mode": "podcast"})
mock_client.chat.set_mode.assert_not_called()
async def test_chat_ask_strips_raw_response_and_lite_references(mcp_call, mock_client) -> None:
"""raw_response is never returned; default references are the lite subset."""
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(
answer="42",
conversation_id=CONV_ID,
raw_response='[["wrb.fr", ... internal wire blob ...]]',
references=[
FakeReference(
source_id="s1",
citation_number=1,
cited_text="quote",
chunk_id="c1",
start_char=10,
score=0.9,
)
],
)
)
result = await mcp_call("chat_ask", {"notebook": NB_ID, "question": "what?"})
sc = result.structured_content
assert "raw_response" not in sc
assert sc["references"] == [{"source_id": "s1", "citation_number": 1, "cited_text": "quote"}]
async def test_chat_ask_tolerates_null_references(mcp_call, mock_client) -> None:
"""A null references value (not a list) must not crash the lite projection."""
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(answer="42", conversation_id=CONV_ID, references=None)
)
result = await mcp_call("chat_ask", {"notebook": NB_ID, "question": "what?"})
assert result.structured_content["references"] == []
async def test_chat_ask_full_references_keep_chunk_detail(mcp_call, mock_client) -> None:
mock_client.chat.ask = AsyncMock(
return_value=FakeAskResult(
answer="42",
conversation_id=CONV_ID,
references=[FakeReference(source_id="s1", citation_number=1, chunk_id="c1", score=0.9)],
)
)
result = await mcp_call(
"chat_ask", {"notebook": NB_ID, "question": "what?", "references": "full"}
)
assert result.structured_content["references"][0]["chunk_id"] == "c1"
async def test_chat_configure_rejects_bad_response_length(mcp_call, mock_client) -> None:
"""An out-of-enum response_length is rejected at the Literal schema boundary, no RPC."""
mock_client.chat.configure = AsyncMock(return_value=None)
with pytest.raises(ToolError) as excinfo:
await mcp_call("chat_configure", {"notebook": NB_ID, "response_length": "huge"})
msg = str(excinfo.value).lower()
assert "response_length" in msg and "shorter" in msg
mock_client.chat.configure.assert_not_called()
async def test_chat_ask_error_projects_tool_error(mcp_call, mock_client) -> None:
mock_client.chat.ask = AsyncMock(side_effect=ChatError("no conversation recorded"))
with pytest.raises(ToolError) as excinfo:
await mcp_call("chat_ask", {"notebook": NB_ID, "question": "q"})
# ChatError classifies under the LIBRARY ladder -> the generic ERROR code.
assert "ERROR" in str(excinfo.value)
# ---------------------------------------------------------------------------
# suggest_prompts (dedicated tool)
# ---------------------------------------------------------------------------
async def test_suggest_prompts_default_ask(mcp_call, mock_client) -> None:
"""Default surface=ask maps to mode 4 and returns {suggestions:[...]}."""
mock_client.notebooks.suggest_prompts = AsyncMock(
return_value=[FakePromptSuggestion(title="T1", prompt="P1")]
)
result = await mcp_call("suggest_prompts", {"notebook": NB_ID})
assert result.structured_content["notebook_id"] == NB_ID
assert result.structured_content["suggestions"] == [{"title": "T1", "prompt": "P1"}]
mock_client.notebooks.suggest_prompts.assert_awaited_once_with(
NB_ID, source_ids=None, mode=4, query=None
)
@pytest.mark.parametrize(
("surface", "mode"),
[
("ask", 4),
("audio-deep-dive", 1),
("audio-brief", 2),
("audio-critique", 5),
("audio-debate", 6),
("video-explainer", 3),
("video-short", 10),
("quiz", 8),
("flashcards", 9),
],
)
async def test_suggest_prompts_surface_maps_to_mode(mcp_call, mock_client, surface, mode) -> None:
"""Each surface Literal maps to its verified otmP3b mode int (incl. video-short=10)."""
mock_client.notebooks.suggest_prompts = AsyncMock(return_value=[])
await mcp_call("suggest_prompts", {"notebook": NB_ID, "surface": surface})
assert mock_client.notebooks.suggest_prompts.await_args.kwargs["mode"] == mode
async def test_suggest_prompts_source_ids_and_query(mcp_call, mock_client) -> None:
"""source_ids resolved once; empty query normalizes to None."""
mock_client.notebooks.suggest_prompts = AsyncMock(return_value=[])
result = await mcp_call(
"suggest_prompts",
{"notebook": NB_ID, "surface": "quiz", "source_ids": [_SRC_A], "query": "risks"},
)
kwargs = mock_client.notebooks.suggest_prompts.await_args.kwargs
assert kwargs["source_ids"] == [_SRC_A]
assert kwargs["query"] == "risks"
# A source-scoped call echoes the resolved canonical source_ids (#1808).
assert result.structured_content["source_ids"] == [_SRC_A]
# Omitted source_ids => None (all); explicit null query is accepted at the
# schema boundary (query is str | None) and reaches the client as None.
mock_client.notebooks.suggest_prompts = AsyncMock(return_value=[])
await mcp_call("suggest_prompts", {"notebook": NB_ID, "query": None})
kwargs = mock_client.notebooks.suggest_prompts.await_args.kwargs
assert kwargs["source_ids"] is None and kwargs["query"] is None
async def test_suggest_prompts_rejects_bad_surface(mcp_call, mock_client) -> None:
"""An out-of-enum surface is rejected at the Literal schema boundary, no RPC."""
mock_client.notebooks.suggest_prompts = AsyncMock()
with pytest.raises(ToolError):
await mcp_call("suggest_prompts", {"notebook": NB_ID, "surface": "podcast"})
mock_client.notebooks.suggest_prompts.assert_not_called()