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

1941 lines
84 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for the artifact MCP tools.
Drives each tool through the in-memory FastMCP ``Client`` against a server bound
to the mocked ``NotebookLMClient``, asserting the serialized
``structured_content``. Covers each tool's happy path, name-vs-id resolution
reaching the tool, the per-``type`` ``studio_generate`` / ``studio_download``
enum dispatch, the start→status poll shape, and error projection.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from unittest.mock import MagicMock
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._types.artifacts import ( # noqa: E402
QUIZ_VARIANT,
ArtifactStatus,
ArtifactTypeCode,
)
from notebooklm._types.mind_maps import MindMapKind # noqa: E402
from notebooklm.exceptions import ( # noqa: E402 - after importorskip guard
ArtifactNotFoundError,
NotebookNotFoundError,
RateLimitError,
)
from notebooklm.mcp.tools.studio import _KIND_OPTIONS # noqa: E402
from notebooklm.types import Artifact, ArtifactType, GenerationState, Note # noqa: E402
from .conftest import AsyncMock # noqa: E402 - after importorskip guard
NB_ID = "11111111-1111-1111-1111-111111111111"
TASK_ID = "task-abc-123"
def _schema_enum(prop: dict[str, Any]) -> set[str] | None:
"""The JSON-schema ``enum`` for a tool param, or ``None`` if it has none.
Handles BOTH shapes FastMCP/Pydantic emits: a required ``Literal`` renders a
flat ``{"enum": [...]}``; an optional ``Literal[...] | None`` renders
``{"anyOf": [{"enum": [...], "type": "string"}, {"type": "null"}]}``. A
free-text ``str``/``str | None`` param has no ``enum`` branch → ``None``.
"""
if "enum" in prop:
return set(prop["enum"])
# ``anyOf`` is Pydantic v2's shape for ``T | None`` today; also scan ``oneOf``
# so the helper survives a future schema-generation switch to the JSON-Schema
# mutually-exclusive form rather than silently returning ``None``.
for branch in (prop.get("anyOf") or []) + (prop.get("oneOf") or []):
if "enum" in branch:
return set(branch["enum"])
return None
#: Real-``Artifact`` builders for the download core (it filters on
#: ``isinstance(a, Artifact)`` + the int type code + ``is_completed``).
_AUDIO_ARTIFACT = Artifact(
id="art1",
title="Podcast",
_artifact_type=ArtifactTypeCode.AUDIO.value,
status=int(ArtifactStatus.COMPLETED),
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
_QUIZ_ARTIFACT = Artifact(
id="q1",
title="Quiz",
_artifact_type=ArtifactTypeCode.QUIZ.value,
status=int(ArtifactStatus.COMPLETED),
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
_variant=QUIZ_VARIANT,
)
@dataclass
class FakeArtifact:
id: str
title: str
kind: ArtifactType = ArtifactType.AUDIO
is_completed: bool = True
created_at: datetime = field(default_factory=lambda: datetime(2024, 1, 1, tzinfo=timezone.utc))
@dataclass
class FakeMindMap:
"""Minimal ``MindMap`` stand-in for the rename/delete mind-map probes.
``rename_artifact`` reads ``.id`` + ``.kind`` off ``mind_maps.list`` rows;
``delete_artifact`` reads ``.id`` off ``mind_maps.list_note_backed`` rows.
"""
id: str
kind: MindMapKind = MindMapKind.INTERACTIVE
@dataclass
class FakeStatus:
task_id: str
status: GenerationState = GenerationState.COMPLETED
url: str | None = "https://example.com/out.mp3"
error: str | None = None
error_code: str | None = None
metadata: dict[str, Any] | None = None
@property
def is_complete(self) -> bool:
return self.status == GenerationState.COMPLETED
# ---------------------------------------------------------------------------
# studio_list
# ---------------------------------------------------------------------------
@dataclass
class FakeNote:
"""Minimal ``Note`` stand-in for the merged ``studio_list`` projection."""
id: str
title: str
content: str = ""
created_at: datetime | None = None
#: Ids used across the merged studio_list / studio_delete tests.
_NOTE_ID = "dddddddd-dddd-dddd-dddd-dddddddddddd"
def _completed_artifact(art_id: str, title: str) -> Artifact:
"""A real completed audio ``Artifact`` (carries ``.kind`` / ``.status_str`` / ``.url``)."""
return Artifact(
id=art_id,
title=title,
_artifact_type=ArtifactTypeCode.AUDIO.value,
status=int(ArtifactStatus.COMPLETED),
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
async def test_studio_list_merges_notes_and_artifacts(mcp_call, mock_client) -> None:
"""``studio_list`` merges text notes AND artifacts into one ``items`` list."""
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
)
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "My Podcast")])
result = await mcp_call("studio_list", {"notebook": NB_ID})
sc = result.structured_content
assert sc["notebook_id"] == NB_ID
items = sc["items"]
by_type = {it["type"]: it for it in items}
# A text note item — the default (summary) list gives a bounded preview + the
# full-body char_count, NOT the full ``content`` key (discovery token-saver).
assert by_type["note"]["id"] == _NOTE_ID
assert by_type["note"]["content_preview"] == "body"
assert by_type["note"]["char_count"] == 4
assert "content" not in by_type["note"]
# … and an artifact item (hyphenated type + status_label + url).
assert by_type["audio"]["id"] == "art1"
assert by_type["audio"]["status_label"] == "completed"
assert "url" in by_type["audio"]
# Pagination meta is pinned (key is ``items``, never ``notes``/``artifacts``).
assert sc["total"] == 2
assert sc["offset"] == 0
assert sc["has_more"] is False
mock_client.notes.list.assert_awaited_once_with(NB_ID)
mock_client.artifacts.list.assert_awaited_once_with(NB_ID)
async def test_studio_list_resolves_notebook_by_name(mcp_call, mock_client) -> None:
@dataclass
class FakeNotebook:
id: str
title: str
mock_client.notebooks.list = AsyncMock(
return_value=[FakeNotebook(id=NB_ID, title="My Notebook")]
)
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
result = await mcp_call("studio_list", {"notebook": "My Notebook"})
assert result.structured_content["notebook_id"] == NB_ID
assert result.structured_content["items"] == []
mock_client.artifacts.list.assert_awaited_with(NB_ID)
async def test_studio_list_item_single_fetch(mcp_call, mock_client) -> None:
"""``studio_list(item=…)`` returns just the matched item as a 1-element list."""
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
)
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "My Podcast")])
result = await mcp_call("studio_list", {"notebook": NB_ID, "item": "My Podcast"})
sc = result.structured_content
assert (sc["total"], sc["offset"], sc["has_more"]) == (1, 0, False)
assert len(sc["items"]) == 1
assert sc["items"][0]["id"] == "art1"
assert sc["items"][0]["type"] == "audio"
async def test_studio_list_item_not_found_projects_tool_error(mcp_call, mock_client) -> None:
"""A ref that matches no note or artifact is a NOT_FOUND error."""
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
with pytest.raises(ToolError) as excinfo:
await mcp_call("studio_list", {"notebook": NB_ID, "item": "No Such Thing"})
assert "NOT_FOUND" in str(excinfo.value)
async def test_studio_list_kind_filter(mcp_call, mock_client) -> None:
"""``kind`` filters the merged list to one ``type``."""
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
)
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "My Podcast")])
result = await mcp_call("studio_list", {"notebook": NB_ID, "kind": "note"})
items = result.structured_content["items"]
assert len(items) == 1
assert items[0]["type"] == "note"
assert items[0]["id"] == _NOTE_ID
@pytest.mark.parametrize("bad", ["mind_map", "slide_deck", "note-backed", "bogus", "unknown"])
async def test_studio_list_rejects_unknown_kind(mcp_call, mock_client, bad) -> None:
"""An unknown/underscored ``kind`` rejects at the ``Literal`` schema boundary
(pydantic ``literal_error``), NOT via the old runtime ``"unknown kind"`` VALIDATION
path — mirroring ``studio_generate``'s out-of-enum option rejection. ``unknown`` is a
display-only pass-through value, not a filterable kind, so it's rejected too; the
underscored forms (``mind_map`` / ``slide_deck``) are not the hyphenated members."""
with pytest.raises(ToolError) as exc:
await mcp_call("studio_list", {"notebook": NB_ID, "kind": bad})
msg = str(exc.value)
assert "literal_error" in msg
assert "VALIDATION" not in msg
async def test_studio_list_kind_enum_matches_studio_kinds(mcp_list_tools) -> None:
"""The ``kind`` param's schema ``enum`` is pinned equal to ``STUDIO_KINDS`` so the
hand-spelled signature ``Literal`` can't drift from the runtime source of truth
(``STUDIO_KINDS`` is a frozenset, so it can't BE the ``Literal`` directly).
Also asserts ``"cinematic-video"`` is absent: it is a ``studio_generate.artifact_type``
member but NOT an ``ArtifactType`` / studio kind, so a future ``ArtifactType`` addition
can't silently widen this filter via a stale Literal."""
from notebooklm.mcp.tools._studio_items import STUDIO_KINDS
tools = await mcp_list_tools()
schema = next(t for t in tools if t.name == "studio_list").inputSchema
enum = _schema_enum(schema["properties"]["kind"])
assert enum == STUDIO_KINDS
assert enum is not None and len(enum) == 10
assert "cinematic-video" not in enum
async def test_studio_list_summary_truncates_long_note(mcp_call, mock_client) -> None:
"""A note longer than NOTE_PREVIEW_CHARS → preview capped at NOTE_PREVIEW_CHARS + ``…``,
``char_count`` is the FULL body length, and the full ``content`` key is dropped."""
from notebooklm.mcp.tools._studio_items import NOTE_PREVIEW_CHARS
body = "x" * (NOTE_PREVIEW_CHARS + 50)
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="Long", content=body)]
)
mock_client.artifacts.list = AsyncMock(return_value=[])
result = await mcp_call("studio_list", {"notebook": NB_ID})
note = result.structured_content["items"][0]
assert note["content_preview"] == "x" * NOTE_PREVIEW_CHARS + "…"
assert note["char_count"] == NOTE_PREVIEW_CHARS + 50
assert "content" not in note
@pytest.mark.parametrize(
"body, expected_preview, expected_count",
[
pytest.param("y" * 200, "y" * 200, 200, id="exactly-preview-chars-no-ellipsis"),
pytest.param("", "", 0, id="empty-body"),
pytest.param(None, "", 0, id="none-body"),
],
)
async def test_studio_list_summary_boundary_and_empty(
mcp_call, mock_client, body, expected_preview, expected_count
) -> None:
"""Boundary/empty note bodies: exactly NOTE_PREVIEW_CHARS chars → no ``…``; an empty
or ``None`` body → ``content_preview=""`` / ``char_count=0`` (no crash)."""
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="Edge", content=body)]
)
mock_client.artifacts.list = AsyncMock(return_value=[])
result = await mcp_call("studio_list", {"notebook": NB_ID})
note = result.structured_content["items"][0]
assert note["content_preview"] == expected_preview
assert note["char_count"] == expected_count
assert "content" not in note
async def test_studio_list_detail_full_returns_body(mcp_call, mock_client) -> None:
"""``detail="full"`` returns each note's full ``content`` (no preview/char_count)."""
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="the full body")]
)
mock_client.artifacts.list = AsyncMock(return_value=[])
result = await mcp_call("studio_list", {"notebook": NB_ID, "detail": "full"})
note = result.structured_content["items"][0]
assert note["content"] == "the full body"
assert "content_preview" not in note
assert "char_count" not in note
async def test_studio_list_item_single_fetch_returns_full_body(mcp_call, mock_client) -> None:
"""``item=<note ref>`` returns the note's FULL ``content`` even under the default
summary mode — the single-fetch path is how a full body stays reachable."""
body = "z" * 500
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content=body)]
)
mock_client.artifacts.list = AsyncMock(return_value=[])
result = await mcp_call("studio_list", {"notebook": NB_ID, "item": "My Note"})
note = result.structured_content["items"][0]
assert note["content"] == body
assert "content_preview" not in note
async def test_studio_list_summary_leaves_artifacts_untouched(mcp_call, mock_client) -> None:
"""An artifact item (no body) is identical in summary vs full — no preview/char_count."""
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "My Podcast")])
summary = await mcp_call("studio_list", {"notebook": NB_ID})
full = await mcp_call("studio_list", {"notebook": NB_ID, "detail": "full"})
assert summary.structured_content["items"] == full.structured_content["items"]
art = summary.structured_content["items"][0]
assert "content_preview" not in art
assert "char_count" not in art
async def test_studio_list_rejects_bad_detail(mcp_call, mock_client) -> None:
"""A ``detail`` outside the compact|summary|full enum is rejected at the schema
boundary (mirrors ``source_read``'s invalid-``detail`` test)."""
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
with pytest.raises(ToolError):
await mcp_call("studio_list", {"notebook": NB_ID, "detail": "bogus"})
async def test_studio_list_compact(mcp_call, mock_client) -> None:
"""``detail="compact"`` projects every item — note AND artifact — to a uniform
5-field roster row (``id, title, type, status_label, created_at``), no body/url.
A note carries its real ``created_at`` but no status (``status_label=None``); an
artifact carries both. ``created_at`` is already-fetched data the default
projection drops."""
mock_client.notes.list = AsyncMock(
return_value=[
FakeNote(
id=_NOTE_ID,
title="My Note",
content="x" * 500,
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
)
]
)
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "My Podcast")])
result = await mcp_call("studio_list", {"notebook": NB_ID, "detail": "compact"})
items = {it["type"]: it for it in result.structured_content["items"]}
assert set(items["note"]) == {"id", "title", "type", "status_label", "created_at"}
assert items["note"] == {
"id": _NOTE_ID,
"title": "My Note",
"type": "note",
"status_label": None,
"created_at": "2024-01-02T00:00:00+00:00",
}
assert set(items["audio"]) == {"id", "title", "type", "status_label", "created_at"}
assert items["audio"]["status_label"] == "completed"
assert items["audio"]["created_at"] == "2024-01-01T00:00:00+00:00"
async def test_studio_list_compact_null_created_at(mcp_call, mock_client) -> None:
"""A still-processing artifact (``created_at=None``) serializes ``created_at`` to
``None`` in the compact row — mirrors the source-side null test."""
art = Artifact(
id="art1",
title="Generating",
_artifact_type=ArtifactTypeCode.AUDIO.value,
status=int(ArtifactStatus.PROCESSING),
created_at=None,
)
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[art])
result = await mcp_call("studio_list", {"notebook": NB_ID, "detail": "compact"})
row = result.structured_content["items"][0]
assert set(row) == {"id", "title", "type", "status_label", "created_at"}
assert row["created_at"] is None
async def test_studio_list_compact_composes_with_kind_filter(mcp_call, mock_client) -> None:
"""``detail="compact"`` still honors the ``kind`` filter (shaping is orthogonal)."""
mock_client.notes.list = AsyncMock(return_value=[FakeNote(id=_NOTE_ID, title="N", content="b")])
mock_client.artifacts.list = AsyncMock(return_value=[_completed_artifact("art1", "Pod")])
result = await mcp_call("studio_list", {"notebook": NB_ID, "detail": "compact", "kind": "note"})
rows = result.structured_content["items"]
assert [r["type"] for r in rows] == ["note"]
assert set(rows[0]) == {"id", "title", "type", "status_label", "created_at"}
async def test_studio_list_compact_item_path_unaffected(mcp_call, mock_client) -> None:
"""``item=<ref>`` returns the full item even with ``detail="compact"`` — the
single-fetch path ignores ``detail`` (unchanged contract)."""
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="the full body")]
)
mock_client.artifacts.list = AsyncMock(return_value=[])
result = await mcp_call(
"studio_list", {"notebook": NB_ID, "item": "My Note", "detail": "compact"}
)
note = result.structured_content["items"][0]
assert note["content"] == "the full body"
assert "created_at" not in note
async def test_studio_items_created_at_opt_in() -> None:
"""``studio_items`` omits ``created_at`` by default (default paths byte-identical)
and includes it only when ``include_created_at=True``."""
from notebooklm.mcp.tools._studio_items import studio_items
client = MagicMock()
client.notes.list = AsyncMock(
return_value=[
FakeNote(
id=_NOTE_ID,
title="N",
content="b",
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
)
]
)
client.artifacts.list = AsyncMock(return_value=[])
default = await studio_items(client, NB_ID)
assert "created_at" not in default[0]
enriched = await studio_items(client, NB_ID, include_created_at=True)
assert enriched[0]["created_at"] == "2024-01-02T00:00:00+00:00"
# ---------------------------------------------------------------------------
# studio_generate
# ---------------------------------------------------------------------------
async def test_artifact_generate_audio(mcp_call, mock_client) -> None:
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
result = await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "audio"})
assert result.structured_content["kind"] == "audio"
assert result.structured_content["task_id"] == TASK_ID
mock_client.artifacts.generate_audio.assert_awaited_once()
# notebook id is the first positional arg.
assert mock_client.artifacts.generate_audio.await_args.args[0] == NB_ID
async def test_artifact_generate_quiz_routes_to_quiz(mcp_call, mock_client) -> None:
mock_client.artifacts.generate_quiz = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
result = await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "quiz"})
assert result.structured_content["kind"] == "quiz"
mock_client.artifacts.generate_quiz.assert_awaited_once()
async def test_artifact_generate_video_routes_to_video(mcp_call, mock_client) -> None:
mock_client.artifacts.generate_video = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "video"})
mock_client.artifacts.generate_video.assert_awaited_once()
async def test_artifact_generate_report_routes_to_report(mcp_call, mock_client) -> None:
mock_client.artifacts.generate_report = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "report", "report_format": "study-guide"},
)
mock_client.artifacts.generate_report.assert_awaited_once()
async def test_artifact_generate_passes_source_ids(mcp_call, mock_client) -> None:
# Full-UUID source ids take resolve_source's fast path (no listing) and pass
# straight through — the style MCP supplies.
src_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
src_b = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
result = await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": [src_a, src_b]},
)
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
assert kwargs["source_ids"] == (src_a, src_b)
# A source-scoped generation echoes the resolved canonical source_ids (#1808).
assert result.structured_content["source_ids"] == [src_a, src_b]
async def test_artifact_generate_resolves_source_id_prefix(mcp_call, mock_client) -> None:
"""A non-UUID source ref is resolved to its full id (like every sibling tool),
not forwarded raw to the backend."""
@dataclass
class _Src:
id: str
title: str = "Doc"
full = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
mock_client.sources.list = AsyncMock(return_value=[_Src(id=full)])
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": [full[:12]]},
)
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
assert kwargs["source_ids"] == (full,)
async def test_artifact_generate_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."""
@dataclass
class _Src:
id: str
title: str | None
src_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
src_b = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
mock_client.sources.list = AsyncMock(
return_value=[_Src(id=src_a, title="Alpha"), _Src(id=src_b, title="Beta")]
)
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": ["Beta", "Alpha"]},
)
mock_client.sources.list.assert_awaited_once_with(NB_ID)
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
assert kwargs["source_ids"] == (src_b, src_a)
async def test_artifact_generate_omitting_source_ids_uses_all(mcp_call, mock_client) -> None:
"""Omitting ``source_ids`` must pass ``source_ids=None`` (=> all sources), NOT an
empty tuple. An empty list reaches the backend as 'zero sources', which it refuses
for source-needing kinds (quiz/audio/flashcards), returning a null id surfaced as
'… generation is unavailable'."""
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "audio"})
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
assert kwargs["source_ids"] is None
async def test_artifact_generate_empty_source_ids_uses_all(mcp_call, mock_client) -> None:
"""An EXPLICIT empty list is the same contract as omitting: => None (all sources),
never [] (which the backend refuses). Pins the full empty-vs-None contract."""
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate", {"notebook": NB_ID, "artifact_type": "audio", "source_ids": []}
)
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
assert kwargs["source_ids"] is None
# Full-UUID source ids take resolve_source's fast path (no listing needed), so the
# string-shape coercion tests below need no ``sources.list`` mock.
_SRC_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_SRC_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
async def test_artifact_generate_source_ids_json_string(mcp_call, mock_client) -> None:
"""``source_ids`` sent as a JSON-array string is tolerated (coerce_list)."""
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": f'["{_SRC_A}","{_SRC_B}"]'},
)
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
assert kwargs["source_ids"] == (_SRC_A, _SRC_B)
async def test_artifact_generate_source_ids_comma_string(mcp_call, mock_client) -> None:
"""``source_ids`` sent as a comma-separated string is tolerated (coerce_list)."""
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": f"{_SRC_A},{_SRC_B}"},
)
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
assert kwargs["source_ids"] == (_SRC_A, _SRC_B)
async def test_artifact_generate_source_ids_scalar_string(mcp_call, mock_client) -> None:
"""``source_ids`` sent as a bare scalar string is tolerated (coerce_list)."""
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": _SRC_A},
)
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
assert kwargs["source_ids"] == (_SRC_A,)
async def test_artifact_generate_source_ids_empty_string_uses_all(mcp_call, mock_client) -> None:
"""An empty string coerces to [] => collapses to None (all sources)."""
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": ""},
)
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
assert kwargs["source_ids"] is None
async def test_artifact_generate_source_ids_whitespace_uses_all(mcp_call, mock_client) -> None:
"""A whitespace-only string coerces to [] => collapses to None (all sources)."""
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "audio", "source_ids": " "},
)
kwargs = mock_client.artifacts.generate_audio.await_args.kwargs
assert kwargs["source_ids"] is None
async def test_artifact_generate_unknown_type_is_validation_error(mcp_call, mock_client) -> None:
"""An unknown artifact_type is rejected at the Literal schema boundary."""
with pytest.raises(ToolError) as excinfo:
await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "bogus"})
assert "audio" in str(excinfo.value) and "report" in str(excinfo.value)
async def test_artifact_generate_bad_language_is_validation_error(mcp_call, mock_client) -> None:
"""An unsupported ``language`` projects as VALIDATION up front (not forwarded raw)."""
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "audio", "language": "klingon"},
)
assert "VALIDATION" in str(excinfo.value)
mock_client.artifacts.generate_audio.assert_not_called()
async def test_artifact_generate_valid_language_passes(mcp_call, mock_client) -> None:
"""A supported language code is accepted and forwarded."""
mock_client.artifacts.generate_audio = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
result = await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "audio", "language": "es"},
)
assert result.structured_content["kind"] == "audio"
mock_client.artifacts.generate_audio.assert_awaited_once()
# ---------------------------------------------------------------------------
# studio_generate — per-kind options (#1654)
# ---------------------------------------------------------------------------
async def test_artifact_generate_video_options(mcp_call, mock_client) -> None:
"""video format/style/style_prompt all reach generate_video (custom style path)."""
mock_client.artifacts.generate_video = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{
"notebook": NB_ID,
"artifact_type": "video",
"video_format": "brief",
"style": "custom",
"style_prompt": "hand-drawn diagrams",
},
)
kwargs = mock_client.artifacts.generate_video.await_args.kwargs
assert kwargs["video_format"].name == "BRIEF"
assert kwargs["video_style"].name == "CUSTOM"
assert kwargs["style_prompt"] == "hand-drawn diagrams"
async def test_artifact_generate_slide_deck_options(mcp_call, mock_client) -> None:
mock_client.artifacts.generate_slide_deck = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{
"notebook": NB_ID,
"artifact_type": "slide-deck",
"deck_format": "presenter",
"deck_length": "short",
},
)
kwargs = mock_client.artifacts.generate_slide_deck.await_args.kwargs
assert kwargs["slide_format"].name == "PRESENTER_SLIDES"
assert kwargs["slide_length"].name == "SHORT"
async def test_artifact_generate_infographic_options(mcp_call, mock_client) -> None:
mock_client.artifacts.generate_infographic = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
await mcp_call(
"studio_generate",
{
"notebook": NB_ID,
"artifact_type": "infographic",
"orientation": "portrait",
"detail": "detailed",
"style": "professional",
},
)
kwargs = mock_client.artifacts.generate_infographic.await_args.kwargs
assert kwargs["orientation"].name == "PORTRAIT"
assert kwargs["detail_level"].name == "DETAILED"
assert kwargs["style"].name == "PROFESSIONAL"
async def test_artifact_generate_mind_map_interactive_default(mcp_call, mock_client) -> None:
"""Omitted ``map_kind`` defaults to interactive → routes to ``mind_maps.generate``."""
mock_client.mind_maps.generate = AsyncMock(return_value={"id": "mm1"})
await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "mind-map"})
mock_client.mind_maps.generate.assert_awaited_once()
mock_client.artifacts.generate_mind_map.assert_not_called()
async def test_artifact_generate_mind_map_note_backed_routes(mcp_call, mock_client) -> None:
"""``map_kind=note-backed`` routes to ``artifacts.generate_mind_map`` instead."""
mock_client.artifacts.generate_mind_map = AsyncMock(return_value={"id": "mm1"})
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "mind-map", "map_kind": "note-backed"},
)
mock_client.artifacts.generate_mind_map.assert_awaited_once()
mock_client.mind_maps.generate.assert_not_called()
async def test_artifact_generate_mind_map_forwards_instructions(mcp_call, mock_client) -> None:
"""``instructions`` reaches the mind-map client call (the dropped-instructions fix).
MCP stores the tool ``instructions`` arg as ``raw_args["description"]``, but the
mind-map plan reads ``raw_args["instructions"]`` — so MCP also sets that key. Without
the fix, mind-map instructions were silently discarded.
"""
mock_client.mind_maps.generate = AsyncMock(return_value={"id": "mm1"})
await mcp_call(
"studio_generate",
{
"notebook": NB_ID,
"artifact_type": "mind-map",
"instructions": "focus on the timeline",
},
)
kwargs = mock_client.mind_maps.generate.await_args.kwargs
assert kwargs["instructions"] == "focus on the timeline"
@pytest.mark.parametrize(
"artifact_type,opts",
[
("video", {"style": "professional"}), # infographic-only value, invalid for video
("infographic", {"style": "classic"}), # video-only value, invalid for infographic
],
ids=["video-bad-style", "infographic-bad-style"],
)
async def test_artifact_generate_cross_kind_style_is_validation_error(
mcp_call, mock_client, artifact_type: str, opts: dict
) -> None:
"""A ``style`` value that IS in the global union Literal but invalid for THIS kind
projects as VALIDATION via the runtime ``_KIND_OPTIONS`` loop.
``style`` is a single union Literal (video infographic), so these values pass
the schema boundary and must be narrowed per-kind at runtime — proving the
video/infographic style sets stay enforced separately (they overlap only on
auto/anime/kawaii).
"""
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": artifact_type, **opts},
)
msg = str(excinfo.value)
assert "VALIDATION" in msg
# ...and NOT a boundary rejection: these values are in the global union Literal,
# so they pass Pydantic and are caught by the runtime per-kind narrowing.
assert "literal_error" not in msg
@pytest.mark.parametrize(
"artifact_type,opts,accepted",
[
(
"report",
{"report_format": "nonsense"},
("briefing-doc", "study-guide", "blog-post", "custom"),
),
("mind-map", {"map_kind": "bogus"}, ("interactive", "note-backed")),
("slide-deck", {"deck_format": "nonsense"}, ("detailed", "presenter")),
# A value outside the GLOBAL union ``style`` Literal rejects at the boundary
# too (distinct from the cross-kind cases above, which ARE in the union).
(
"video",
{"style": "nonsense"},
tuple(
# sorted() so the parametrize id / member order is deterministic
# across runs (set iteration order varies with hash randomization).
sorted(
set(_KIND_OPTIONS["video"]["style"])
| set(_KIND_OPTIONS["infographic"]["style"])
)
),
),
],
ids=["bad-report-format", "bad-map-kind", "bad-deck-format", "out-of-union-style"],
)
async def test_artifact_generate_bad_option_value_is_schema_boundary_error(
mcp_call, mock_client, artifact_type: str, opts: dict, accepted: tuple[str, ...]
) -> None:
"""An out-of-enum value for a ``Literal`` option rejects at the schema boundary
(pydantic ``literal_error``), surfacing the accepted members — NOT the runtime
``"VALIDATION"`` projection (which only fires for values that pass the boundary,
i.e. the cross-kind ``style`` cases above).
This is the point of the Literal typing: bad values reject earlier (no
``"VALIDATION"`` substring — same as the unknown-``artifact_type`` case), with
the schema enum surfaced to the agent. The ``"VALIDATION" not in`` +
``literal_error in`` assertions are what actually distinguish a boundary
rejection from the runtime path (both list the accepted members)."""
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": artifact_type, **opts},
)
msg = str(excinfo.value)
assert all(member in msg for member in accepted)
assert "VALIDATION" not in msg
assert "literal_error" in msg
@pytest.mark.parametrize(
"artifact_type,opts",
[
("quiz", {"orientation": "portrait"}), # infographic option on quiz
("video", {"deck_format": "presenter"}), # slide-deck option on video
("audio", {"video_format": "brief"}), # video option on audio
("video", {"map_kind": "interactive"}), # mind-map option on video
("cinematic-video", {"style": "classic"}), # cinematic-video exposes NO options
],
ids=[
"orientation-on-quiz",
"deck-on-video",
"video-on-audio",
"mapkind-on-video",
"style-on-cinematic",
],
)
async def test_artifact_generate_wrong_kind_option_is_validation_error(
mcp_call, mock_client, artifact_type: str, opts: dict
) -> None:
"""An option valid for some OTHER kind is rejected, not silently ignored.
The neutral core ignores irrelevant extras, so this rejection lives in the MCP tool;
without it an agent's mis-targeted option would silently no-op.
"""
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": artifact_type, **opts},
)
assert "VALIDATION" in str(excinfo.value)
async def test_artifact_generate_wrong_kind_message_for_optionless_kind(
mcp_call, mock_client
) -> None:
"""A kind with no per-kind options reports that clearly (not ``accepts []``)."""
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "cinematic-video", "style": "classic"},
)
assert "no per-kind options" in str(excinfo.value)
async def test_artifact_generate_style_prompt_requires_custom(mcp_call, mock_client) -> None:
"""``style_prompt`` without ``style=custom`` is rejected (core cross-field rule)."""
mock_client.artifacts.generate_video = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_generate",
{"notebook": NB_ID, "artifact_type": "video", "style_prompt": "hand-drawn"},
)
assert "VALIDATION" in str(excinfo.value)
def test_kind_options_match_core_maps() -> None:
"""The MCP per-kind choice tuples are DUPLICATED from the core's private maps (the
CLI/MCP boundary forbids importing them at runtime). Pin them equal so they can't
silently drift — the parity tests only exercise valid values and would miss a
*subset* drift (MCP wrongly rejecting a value the core accepts)."""
from notebooklm._app import generate_plans as gp
from notebooklm.mcp.tools.studio import _KIND_OPTIONS
assert _KIND_OPTIONS["audio"]["audio_format"] == tuple(gp._AUDIO_FORMAT_MAP)
assert _KIND_OPTIONS["audio"]["audio_length"] == tuple(gp._AUDIO_LENGTH_MAP)
assert _KIND_OPTIONS["video"]["video_format"] == tuple(gp._VIDEO_FORMAT_MAP)
assert _KIND_OPTIONS["video"]["style"] == tuple(gp._VIDEO_STYLE_MAP)
assert _KIND_OPTIONS["slide-deck"]["deck_format"] == tuple(gp._SLIDE_FORMAT_MAP)
assert _KIND_OPTIONS["slide-deck"]["deck_length"] == tuple(gp._SLIDE_LENGTH_MAP)
assert _KIND_OPTIONS["quiz"]["quantity"] == tuple(gp._QUIZ_QUANTITY_MAP)
assert _KIND_OPTIONS["quiz"]["difficulty"] == tuple(gp._QUIZ_DIFFICULTY_MAP)
# flashcards reuses the same core maps today; pin independently so a future
# flashcards-specific map can't drift the MCP set unnoticed.
assert _KIND_OPTIONS["flashcards"]["quantity"] == tuple(gp._QUIZ_QUANTITY_MAP)
assert _KIND_OPTIONS["flashcards"]["difficulty"] == tuple(gp._QUIZ_DIFFICULTY_MAP)
assert _KIND_OPTIONS["infographic"]["orientation"] == tuple(gp._INFOGRAPHIC_ORIENTATION_MAP)
assert _KIND_OPTIONS["infographic"]["detail"] == tuple(gp._INFOGRAPHIC_DETAIL_MAP)
assert _KIND_OPTIONS["infographic"]["style"] == tuple(gp._INFOGRAPHIC_STYLE_MAP)
assert _KIND_OPTIONS["report"]["report_format"] == tuple(gp._REPORT_FORMAT_MAP)
async def test_artifact_generate_exposes_new_option_params(mcp_list_tools) -> None:
"""The agent-facing tool schema exposes every new per-kind option parameter."""
tools = await mcp_list_tools()
schema = next(t for t in tools if t.name == "studio_generate").inputSchema
properties = schema.get("properties", {})
for param in (
"video_format",
"style",
"style_prompt",
"deck_format",
"deck_length",
"orientation",
"detail",
"map_kind",
):
assert param in properties, f"studio_generate must expose {param!r}"
async def test_artifact_generate_option_params_expose_enums(mcp_list_tools) -> None:
"""Each finite-choice option param is typed ``Literal`` → the tool schema exposes a
JSON-schema ``enum`` matching ``_KIND_OPTIONS`` (acceptance criterion for #1666).
The expected enum is read from ``_KIND_OPTIONS`` (pinned equal to the neutral core
maps by ``test_kind_options_match_core_maps``), so a core-map change not mirrored
into BOTH ``_KIND_OPTIONS`` and the signature ``Literal`` fails here. ``style`` is a
single union Literal, so its enum is the union across video+infographic; ``quantity``
/``difficulty`` are shared by quiz+flashcards (identical today — assert the union so
a future flashcards-specific set is still covered)."""
tools = await mcp_list_tools()
schema = next(t for t in tools if t.name == "studio_generate").inputSchema
props = schema.get("properties", {})
# Single-kind option params: enum == that kind's choice set.
single_kind = {
"report_format": "report",
"audio_format": "audio",
"audio_length": "audio",
"video_format": "video",
"deck_format": "slide-deck",
"deck_length": "slide-deck",
"orientation": "infographic",
"detail": "infographic",
"map_kind": "mind-map",
}
for param, kind in single_kind.items():
assert _schema_enum(props[param]) == set(_KIND_OPTIONS[kind][param]), param
# quantity/difficulty: shared by quiz+flashcards (union).
for param in ("quantity", "difficulty"):
expected = set(_KIND_OPTIONS["quiz"][param]) | set(_KIND_OPTIONS["flashcards"][param])
assert _schema_enum(props[param]) == expected, param
# style: single union Literal across video + infographic.
expected_style = set(_KIND_OPTIONS["video"]["style"]) | set(
_KIND_OPTIONS["infographic"]["style"]
)
assert _schema_enum(props["style"]) == expected_style
async def test_artifact_generate_free_text_params_have_no_enum(mcp_list_tools) -> None:
"""``style_prompt`` and ``language`` stay free text — NOT converted to ``Literal``.
Uses the same nested-aware ``_schema_enum`` helper so an accidental conversion that
hid an ``enum`` inside an ``anyOf`` branch would still be caught."""
tools = await mcp_list_tools()
schema = next(t for t in tools if t.name == "studio_generate").inputSchema
props = schema.get("properties", {})
assert _schema_enum(props["style_prompt"]) is None
assert _schema_enum(props["language"]) is None
# ---------------------------------------------------------------------------
# studio_status (stateless poll)
# ---------------------------------------------------------------------------
async def test_artifact_status(mcp_call, mock_client) -> None:
mock_client.artifacts.poll_status = AsyncMock(return_value=FakeStatus(task_id=TASK_ID))
result = await mcp_call("studio_status", {"notebook": NB_ID, "task_id": TASK_ID})
assert result.structured_content["task_id"] == TASK_ID
assert result.structured_content["is_complete"] is True
assert result.structured_content["status"] == GenerationState.COMPLETED.value
mock_client.artifacts.poll_status.assert_awaited_once_with(NB_ID, TASK_ID)
async def test_artifact_generate_then_status_poll_shape(mcp_call, mock_client) -> None:
"""The start→status poll loop: generate returns a task_id, status polls it."""
mock_client.artifacts.generate_audio = AsyncMock(
return_value=FakeStatus(task_id=TASK_ID, status=GenerationState.PENDING, url=None)
)
started = await mcp_call("studio_generate", {"notebook": NB_ID, "artifact_type": "audio"})
task_id = started.structured_content["task_id"]
assert task_id == TASK_ID
mock_client.artifacts.poll_status = AsyncMock(
return_value=FakeStatus(task_id=TASK_ID, status=GenerationState.COMPLETED)
)
polled = await mcp_call("studio_status", {"notebook": NB_ID, "task_id": task_id})
assert polled.structured_content["is_complete"] is True
# ---------------------------------------------------------------------------
# studio_get_prompt
# ---------------------------------------------------------------------------
async def test_artifact_get_prompt(mcp_call, mock_client) -> None:
"""Happy path: the stored prompt string flows through unchanged."""
mock_client.artifacts.get_prompt = AsyncMock(return_value="Summarize the intro")
result = await mcp_call("studio_get_prompt", {"notebook": NB_ID, "artifact": _ART_FULL})
assert result.structured_content == {
"notebook_id": NB_ID,
"artifact_id": _ART_FULL,
"prompt": "Summarize the intro",
}
# Full-UUID ref fast-paths: the resolver never lists artifacts.
mock_client.artifacts.list.assert_not_called()
mock_client.artifacts.get_prompt.assert_awaited_once_with(NB_ID, _ART_FULL)
async def test_artifact_get_prompt_none_is_success(mcp_call, mock_client) -> None:
"""``prompt=None`` (artifact records no prompt) is a valid result, not an error."""
mock_client.artifacts.get_prompt = AsyncMock(return_value=None)
result = await mcp_call("studio_get_prompt", {"notebook": NB_ID, "artifact": _ART_FULL})
assert result.structured_content == {
"notebook_id": NB_ID,
"artifact_id": _ART_FULL,
"prompt": None,
}
async def test_artifact_get_prompt_resolves_by_title(mcp_call, mock_client) -> None:
"""A title/prefix ref resolves to the artifact id before the prompt fetch."""
art = Artifact(
id=_ART_FULL,
title="Podcast 1",
_artifact_type=ArtifactTypeCode.AUDIO.value,
status=int(ArtifactStatus.COMPLETED),
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
mock_client.artifacts.list = AsyncMock(return_value=[art])
mock_client.artifacts.get_prompt = AsyncMock(return_value="From the podcast")
result = await mcp_call("studio_get_prompt", {"notebook": NB_ID, "artifact": "Podcast 1"})
assert result.structured_content["artifact_id"] == _ART_FULL
assert result.structured_content["prompt"] == "From the podcast"
mock_client.artifacts.get_prompt.assert_awaited_once_with(NB_ID, _ART_FULL)
async def test_artifact_get_prompt_unknown_id_projects_tool_error(mcp_call, mock_client) -> None:
"""An unknown id raises ``ArtifactNotFoundError`` (mapped to NOT_FOUND).
``get_prompt`` has no pre-list existence guard — the full-UUID ref reaches the
client, whose ``get_prompt`` raises for an absent artifact."""
def _raise(*_a: Any, **_k: Any) -> Any:
raise ArtifactNotFoundError(_ART_FULL)
mock_client.artifacts.get_prompt = AsyncMock(side_effect=_raise)
with pytest.raises(ToolError) as excinfo:
await mcp_call("studio_get_prompt", {"notebook": NB_ID, "artifact": _ART_FULL})
assert "NOT_FOUND" in str(excinfo.value)
# ---------------------------------------------------------------------------
# studio_download
# ---------------------------------------------------------------------------
async def test_artifact_download_audio(mcp_call, mock_client, tmp_path) -> None:
out = str(tmp_path / "out.mp3")
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
result = await mcp_call(
"studio_download", {"notebook": NB_ID, "artifact_type": "audio", "path": out}
)
assert result.structured_content["notebook_id"] == NB_ID
assert result.structured_content["outcome"] == "single_downloaded"
assert result.structured_content["output_path"] == out
mock_client.artifacts.download_audio.assert_awaited_once()
async def test_artifact_download_by_artifact_ref_infers_type(
mcp_call, mock_client, tmp_path
) -> None:
"""R3: an ``artifact`` name-or-id ref resolves to its type+id — no ``artifact_type``."""
out = str(tmp_path / "out.mp3")
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
result = await mcp_call(
"studio_download", {"notebook": NB_ID, "artifact": "Podcast", "path": out}
)
assert result.structured_content["outcome"] == "single_downloaded"
# The audio downloader was selected purely from the resolved artifact's kind,
# AND the resolved id (not latest-by-type) reached it — guards a regression to
# latest-by-type that a bare assert_awaited_once() would miss.
assert result.structured_content["artifact"]["id"] == "art1"
assert mock_client.artifacts.download_audio.await_args.kwargs["artifact_id"] == "art1"
async def test_artifact_download_ref_and_type_together_is_validation(mcp_call, mock_client) -> None:
"""Passing both ``artifact`` and ``artifact_type`` is rejected (one addressing mode)."""
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
with pytest.raises(ToolError) as exc:
await mcp_call(
"studio_download",
{"notebook": NB_ID, "artifact": "Podcast", "artifact_type": "audio"},
)
assert "not both" in str(exc.value)
async def test_artifact_download_ref_and_id_together_is_validation(mcp_call, mock_client) -> None:
"""Passing `artifact` alongside `artifact_id` is rejected (would silently drop the id)."""
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
with pytest.raises(ToolError) as exc:
await mcp_call(
"studio_download",
{"notebook": NB_ID, "artifact": "Podcast", "artifact_id": "art1"},
)
assert "not both" in str(exc.value)
async def test_artifact_download_neither_ref_nor_type_is_validation(mcp_call, mock_client) -> None:
"""Omitting both ``artifact`` and ``artifact_type`` is rejected."""
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
with pytest.raises(ToolError) as exc:
await mcp_call("studio_download", {"notebook": NB_ID})
assert "artifact_type" in str(exc.value)
async def test_artifact_download_quiz_with_format(mcp_call, mock_client, tmp_path) -> None:
out = str(tmp_path / "quiz.md")
mock_client.artifacts.list = AsyncMock(return_value=[_QUIZ_ARTIFACT])
mock_client.artifacts.download_quiz = AsyncMock(return_value=out)
result = await mcp_call(
"studio_download",
{"notebook": NB_ID, "artifact_type": "quiz", "path": out, "output_format": "markdown"},
)
assert result.structured_content["outcome"] == "single_downloaded"
# The format kwarg flows through to the bound download coroutine.
assert mock_client.artifacts.download_quiz.await_args.kwargs.get("output_format") == "markdown"
async def test_artifact_download_unknown_type_is_validation_error(mcp_call, mock_client) -> None:
"""An unknown download artifact_type is rejected at the Literal schema boundary."""
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_download", {"notebook": NB_ID, "artifact_type": "bogus", "path": "/tmp/x"}
)
assert "audio" in str(excinfo.value) and "flashcards" in str(excinfo.value)
async def test_artifact_download_bad_format_for_supported_type_is_validation(
mcp_call, mock_client, tmp_path
) -> None:
"""A bad ``format`` for a type that DOES support format projects a Literal schema boundary error."""
out = str(tmp_path / "quiz.json")
mock_client.artifacts.list = AsyncMock(return_value=[_QUIZ_ARTIFACT])
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_download",
{"notebook": NB_ID, "artifact_type": "quiz", "path": out, "output_format": "bogus"},
)
assert "validation error" in str(excinfo.value)
async def test_artifact_download_bad_format_cross_validation_is_validation(
mcp_call, mock_client, tmp_path
) -> None:
"""An in-union format value that is invalid for the specific type raises a runtime VALIDATION error."""
out = str(tmp_path / "quiz.json")
mock_client.artifacts.list = AsyncMock(return_value=[_QUIZ_ARTIFACT])
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_download",
{"notebook": NB_ID, "artifact_type": "quiz", "path": out, "output_format": "pdf"},
)
assert "VALIDATION" in str(excinfo.value)
async def test_artifact_download_format_for_unsupported_type_is_validation(
mcp_call, mock_client, tmp_path
) -> None:
"""Supplying ``format`` for a type WITHOUT format choices errors (was silently dropped)."""
out = str(tmp_path / "out.mp3")
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_download",
{"notebook": NB_ID, "artifact_type": "audio", "path": out, "output_format": "pdf"},
)
assert "VALIDATION" in str(excinfo.value)
mock_client.artifacts.download_audio.assert_not_called()
async def test_artifact_download_report_markdown_self_documenting(
mcp_call, mock_client, tmp_path
) -> None:
"""report has no format axis → the rejection tells the caller to omit output_format."""
out = str(tmp_path / "out.md")
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_download",
{
"notebook": NB_ID,
"artifact_type": "report",
"path": out,
"output_format": "markdown",
},
)
msg = str(excinfo.value)
assert "supported formats: default only" in msg
assert "omit output_format" in msg
async def test_artifact_download_audio_pdf_self_documenting(
mcp_call, mock_client, tmp_path
) -> None:
"""audio has no format axis → same self-documenting rejection (pdf is in-union but wrong-type)."""
out = str(tmp_path / "out.mp3")
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_download",
{"notebook": NB_ID, "artifact_type": "audio", "path": out, "output_format": "pdf"},
)
msg = str(excinfo.value)
assert "supported formats: default only" in msg
assert "omit output_format" in msg
mock_client.artifacts.download_audio.assert_not_called()
async def test_artifact_download_supported_type_invalid_format_lists_choices(
mcp_call, mock_client, tmp_path
) -> None:
"""A type WITH a format axis still lists its allowed values on an invalid choice."""
out = str(tmp_path / "quiz.json")
mock_client.artifacts.list = AsyncMock(return_value=[_QUIZ_ARTIFACT])
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_download",
{"notebook": NB_ID, "artifact_type": "quiz", "path": out, "output_format": "pdf"},
)
msg = str(excinfo.value)
assert "expected one of" in msg
assert "json" in msg and "markdown" in msg and "html" in msg
# The no-format-axis wording must NOT leak into a type that has a format axis.
assert "supported formats: default only" not in msg
async def test_artifact_download_no_artifacts(mcp_call, mock_client, tmp_path) -> None:
out = str(tmp_path / "out.mp3")
mock_client.artifacts.list = AsyncMock(return_value=[])
result = await mcp_call(
"studio_download", {"notebook": NB_ID, "artifact_type": "audio", "path": out}
)
assert result.structured_content["outcome"] == "no_artifacts"
_AUDIO_ARTIFACT_1 = Artifact(
id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
title="Podcast 1",
_artifact_type=ArtifactTypeCode.AUDIO.value,
status=int(ArtifactStatus.COMPLETED),
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
_AUDIO_ARTIFACT_2 = Artifact(
id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
title="Podcast 2",
_artifact_type=ArtifactTypeCode.AUDIO.value,
status=int(ArtifactStatus.COMPLETED),
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
)
async def test_artifact_download_by_full_id(mcp_call, mock_client, tmp_path) -> None:
out = str(tmp_path / "out.mp3")
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT_1, _AUDIO_ARTIFACT_2])
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
result = await mcp_call(
"studio_download",
{
"notebook": NB_ID,
"artifact_type": "audio",
"path": out,
"artifact_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
},
)
assert result.structured_content["outcome"] == "single_downloaded"
assert result.structured_content["artifact"]["id"] == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
mock_client.artifacts.download_audio.assert_awaited_once_with(
NB_ID, out, artifact_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
)
async def test_artifact_download_by_unique_prefix(mcp_call, mock_client, tmp_path) -> None:
out = str(tmp_path / "out.mp3")
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT_1, _AUDIO_ARTIFACT_2])
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
result = await mcp_call(
"studio_download",
{
"notebook": NB_ID,
"artifact_type": "audio",
"path": out,
"artifact_id": "bbbbbbbb-bbbb",
},
)
assert result.structured_content["outcome"] == "single_downloaded"
assert result.structured_content["artifact"]["id"] == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
mock_client.artifacts.download_audio.assert_awaited_once_with(
NB_ID, out, artifact_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
)
async def test_artifact_download_by_id_not_found(mcp_call, mock_client, tmp_path) -> None:
# A not-found ``artifact_id`` (a full UUID absent from the list) is a hard miss,
# uniform with a not-found / ambiguous prefix — ``_resolve_artifact_id`` raises
# before the download core's soft ERROR path, mirroring how a bad notebook id
# surfaces (ToolError / NOT_FOUND).
out = str(tmp_path / "out.mp3")
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT_1, _AUDIO_ARTIFACT_2])
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_download",
{
"notebook": NB_ID,
"artifact_type": "audio",
"path": out,
"artifact_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
},
)
assert "not found" in str(excinfo.value)
mock_client.artifacts.download_audio.assert_not_called()
async def test_artifact_download_by_uppercase_full_id(mcp_call, mock_client, tmp_path) -> None:
# An uppercase full UUID must still resolve: resolve_ref fast-paths it verbatim,
# so _resolve_artifact_id case-insensitively matches it back to the list's
# canonical (lowercase) id that select_artifact compares against.
out = str(tmp_path / "out.mp3")
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT_1, _AUDIO_ARTIFACT_2])
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
result = await mcp_call(
"studio_download",
{
"notebook": NB_ID,
"artifact_type": "audio",
"path": out,
"artifact_id": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA",
},
)
assert result.structured_content["outcome"] == "single_downloaded"
assert result.structured_content["artifact"]["id"] == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
mock_client.artifacts.download_audio.assert_awaited_once_with(
NB_ID, out, artifact_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
)
async def test_artifact_download_by_id_ambiguous_prefix(mcp_call, mock_client, tmp_path) -> None:
out = str(tmp_path / "out.mp3")
art_same_1 = Artifact(
id="cccccccc-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
title="Podcast A",
_artifact_type=ArtifactTypeCode.AUDIO.value,
status=int(ArtifactStatus.COMPLETED),
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
art_same_2 = Artifact(
id="cccccccc-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
title="Podcast B",
_artifact_type=ArtifactTypeCode.AUDIO.value,
status=int(ArtifactStatus.COMPLETED),
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
)
mock_client.artifacts.list = AsyncMock(return_value=[art_same_1, art_same_2])
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_download",
{
"notebook": NB_ID,
"artifact_type": "audio",
"path": out,
"artifact_id": "cccccccc",
},
)
assert "Ambiguous ID" in str(excinfo.value)
mock_client.artifacts.download_audio.assert_not_called()
async def test_artifact_download_latest_preserved(mcp_call, mock_client, tmp_path) -> None:
out = str(tmp_path / "out.mp3")
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT_1, _AUDIO_ARTIFACT_2])
mock_client.artifacts.download_audio = AsyncMock(return_value=out)
result = await mcp_call(
"studio_download",
{
"notebook": NB_ID,
"artifact_type": "audio",
"path": out,
},
)
assert result.structured_content["outcome"] == "single_downloaded"
assert result.structured_content["artifact"]["id"] == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
mock_client.artifacts.download_audio.assert_awaited_once_with(
NB_ID, out, artifact_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
)
# ---------------------------------------------------------------------------
# error projection
# ---------------------------------------------------------------------------
async def test_artifact_status_not_found_projects_tool_error(mcp_call, mock_client) -> None:
def _raise(*_a: Any, **_k: Any) -> Any:
raise ArtifactNotFoundError(TASK_ID)
mock_client.artifacts.poll_status = AsyncMock(side_effect=_raise)
with pytest.raises(ToolError) as excinfo:
await mcp_call("studio_status", {"notebook": NB_ID, "task_id": TASK_ID})
assert "NOT_FOUND" in str(excinfo.value)
async def test_artifact_list_notebook_not_found_projects_tool_error(mcp_call, mock_client) -> None:
mock_client.notebooks.list = AsyncMock(return_value=[])
with pytest.raises(ToolError) as excinfo:
await mcp_call("studio_list", {"notebook": "No Such Notebook"})
assert "NOT_FOUND" in str(excinfo.value)
_ = NotebookNotFoundError # imported for symmetry with sibling suites
# ---------------------------------------------------------------------------
# studio_rename
# ---------------------------------------------------------------------------
_ART_FULL = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
async def test_artifact_rename_regular_typed_artifact(mcp_call, mock_client) -> None:
"""A regular artifact resolves via the typed ``Artifact`` list (NOT a dict) and
routes to ``artifacts.rename``.
Regression guard: the resolver must use attribute access (``a.id`` / ``a.title``)
on the typed ``Artifact`` objects ``client.artifacts.list`` returns. The earlier
dict-shaped helper would ``TypeError`` here. Resolving by a hex prefix exercises
the id/prefix path against the typed list.
"""
art = Artifact(
id=_ART_FULL,
title="Podcast 1",
_artifact_type=ArtifactTypeCode.AUDIO.value,
status=int(ArtifactStatus.COMPLETED),
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[art])
mock_client.mind_maps.list = AsyncMock(return_value=[])
mock_client.artifacts.rename = AsyncMock()
result = await mcp_call(
"studio_rename",
{"notebook": NB_ID, "item": "aaaaaaaa-aaaa", "new_title": "Renamed"},
)
assert result.structured_content == {
"status": "renamed",
"notebook_id": NB_ID,
"item_id": _ART_FULL,
"type": "audio",
"new_title": "Renamed",
"is_mind_map": False,
}
mock_client.artifacts.rename.assert_awaited_once_with(
NB_ID, _ART_FULL, "Renamed", return_object=False
)
mock_client.mind_maps.rename.assert_not_called()
async def test_artifact_rename_interactive_mind_map_by_title(mcp_call, mock_client) -> None:
"""A mind map resolved by title routes through ``mind_maps.rename`` (is_mind_map true)."""
mm_id = "mmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm"
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(
return_value=[FakeArtifact(id=mm_id, title="My Map", kind=ArtifactType.MIND_MAP)]
)
mock_client.mind_maps.list = AsyncMock(
return_value=[FakeMindMap(id=mm_id, kind=MindMapKind.INTERACTIVE)]
)
mock_client.mind_maps.rename = AsyncMock()
result = await mcp_call(
"studio_rename",
{"notebook": NB_ID, "item": "My Map", "new_title": "Renamed Map"},
)
assert result.structured_content["is_mind_map"] is True
assert result.structured_content["item_id"] == mm_id
assert result.structured_content["type"] == "mind-map"
mock_client.mind_maps.rename.assert_awaited_once()
mock_client.artifacts.rename.assert_not_called()
async def test_artifact_rename_note_backed_mind_map_by_full_uuid(mcp_call, mock_client) -> None:
"""A note-backed mind map absent from the merged list still renames by full UUID:
the cross-type resolve misses, the full-UUID carve-out routes to the artifact
core, whose ``mind_maps.list`` probe finds it → ``mind_maps.rename`` with its kind."""
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
mock_client.mind_maps.list = AsyncMock(
return_value=[FakeMindMap(id=_ART_FULL, kind=MindMapKind.NOTE_BACKED)]
)
mock_client.mind_maps.rename = AsyncMock()
result = await mcp_call(
"studio_rename",
{"notebook": NB_ID, "item": _ART_FULL, "new_title": "Renamed"},
)
assert result.structured_content["is_mind_map"] is True
assert result.structured_content["item_id"] == _ART_FULL
assert result.structured_content["type"] == "mind-map"
mock_client.mind_maps.rename.assert_awaited_once()
assert mock_client.mind_maps.rename.await_args.kwargs["kind"] == MindMapKind.NOTE_BACKED
async def test_artifact_rename_note_backed_mind_map_by_uppercase_full_uuid(
mcp_call, mock_client
) -> None:
"""An UPPERCASE full UUID in the carve-out is normalized to canonical lowercase
before delegating, so the artifact core's CASE-SENSITIVE ``mind_maps.list`` probe
still finds the note-backed map. Regression: without the ``item.lower()`` the probe
would miss and the tool would mislabel it ``type="unknown"`` / ``is_mind_map=False``."""
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
mock_client.mind_maps.list = AsyncMock(
return_value=[FakeMindMap(id=_ART_FULL, kind=MindMapKind.NOTE_BACKED)]
)
mock_client.mind_maps.rename = AsyncMock()
result = await mcp_call(
"studio_rename",
{"notebook": NB_ID, "item": _ART_FULL.upper(), "new_title": "Renamed"},
)
assert result.structured_content["is_mind_map"] is True
assert result.structured_content["type"] == "mind-map"
# The echoed id is the canonical lowercase form, not the uppercase input.
assert result.structured_content["item_id"] == _ART_FULL
mock_client.mind_maps.rename.assert_awaited_once()
async def test_studio_rename_note_routes_to_note_rename(mcp_call, mock_client) -> None:
"""A resolved NOTE renames via the content-preserving note core (never the
artifact rename RPC), returning ``type="note"`` / ``is_mind_map=False``."""
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
)
mock_client.artifacts.list = AsyncMock(return_value=[])
mock_client.notes.get_or_none = AsyncMock(
return_value=Note(id=_NOTE_ID, notebook_id=NB_ID, title="My Note", content="body")
)
mock_client.notes.update = AsyncMock()
result = await mcp_call(
"studio_rename",
{"notebook": NB_ID, "item": _NOTE_ID, "new_title": "Renamed Note"},
)
assert result.structured_content == {
"status": "renamed",
"notebook_id": NB_ID,
"item_id": _NOTE_ID,
"type": "note",
"new_title": "Renamed Note",
"is_mind_map": False,
}
# Content-preserving: the update carries the existing body, only the title changes.
mock_client.notes.update.assert_awaited_once_with(
NB_ID, _NOTE_ID, content="body", title="Renamed Note"
)
mock_client.artifacts.rename.assert_not_called()
async def test_studio_rename_note_vanished_race_projects_not_found(mcp_call, mock_client) -> None:
"""A note resolved from the list but gone by the content-preserving ``get``
(a concurrent delete won the race) projects NOT_FOUND, not a silent success.
``execute_note_rename`` returns ``found=False`` when ``get_or_none`` yields a
non-``Note``; the tool maps that to a ``ToolError``/NOT_FOUND and never writes."""
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
)
mock_client.artifacts.list = AsyncMock(return_value=[])
mock_client.notes.get_or_none = AsyncMock(return_value=None)
mock_client.notes.update = AsyncMock()
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_rename",
{"notebook": NB_ID, "item": _NOTE_ID, "new_title": "Renamed Note"},
)
assert "NOT_FOUND" in str(excinfo.value)
mock_client.notes.update.assert_not_called()
mock_client.artifacts.rename.assert_not_called()
async def test_artifact_rename_not_found_projects_tool_error(mcp_call, mock_client) -> None:
"""A non-UUID (prefix/title) ref that matches no note or artifact projects
NOT_FOUND (the cross-type resolver raises, ``mcp_errors`` maps to ``ToolError``)."""
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_rename",
{"notebook": NB_ID, "item": "No Such Artifact", "new_title": "X"},
)
assert "NOT_FOUND" in str(excinfo.value)
mock_client.artifacts.rename.assert_not_called()
# The tool layer asserts the wrapped ToolError/NOT_FOUND; the raw
# NotFoundError is asserted at the resolver layer in test_resolve.py.
# ---------------------------------------------------------------------------
# studio_retry
# ---------------------------------------------------------------------------
async def test_artifact_retry_happy_path(mcp_call, mock_client) -> None:
"""A retry returns the kicked-off ``task_id`` (== artifact id) and new status."""
mock_client.artifacts.retry_failed = AsyncMock(
return_value=FakeStatus(task_id=_ART_FULL, status=GenerationState.IN_PROGRESS, url=None)
)
result = await mcp_call("studio_retry", {"notebook": NB_ID, "artifact": _ART_FULL})
assert result.structured_content == {
"notebook_id": NB_ID,
"artifact_id": _ART_FULL,
"task_id": _ART_FULL,
"status": "in_progress",
}
# Full-UUID ref fast-paths: the resolver never lists artifacts.
mock_client.artifacts.list.assert_not_called()
mock_client.artifacts.retry_failed.assert_awaited_once_with(NB_ID, _ART_FULL)
async def test_artifact_retry_resolves_by_title(mcp_call, mock_client) -> None:
"""A title/prefix ref resolves to the artifact id before the retry call."""
art = Artifact(
id=_ART_FULL,
title="Podcast 1",
_artifact_type=ArtifactTypeCode.AUDIO.value,
status=int(ArtifactStatus.FAILED),
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
mock_client.artifacts.list = AsyncMock(return_value=[art])
mock_client.artifacts.retry_failed = AsyncMock(
return_value=FakeStatus(task_id=_ART_FULL, status=GenerationState.IN_PROGRESS, url=None)
)
result = await mcp_call("studio_retry", {"notebook": NB_ID, "artifact": "Podcast 1"})
assert result.structured_content["artifact_id"] == _ART_FULL
assert result.structured_content["task_id"] == _ART_FULL
mock_client.artifacts.retry_failed.assert_awaited_once_with(NB_ID, _ART_FULL)
async def test_artifact_retry_not_found_projects_tool_error(mcp_call, mock_client) -> None:
"""A prefix/title that matches no artifact projects NOT_FOUND at resolve time."""
mock_client.artifacts.list = AsyncMock(return_value=[])
mock_client.artifacts.retry_failed = AsyncMock()
with pytest.raises(ToolError) as excinfo:
await mcp_call("studio_retry", {"notebook": NB_ID, "artifact": "No Such Artifact"})
assert "NOT_FOUND" in str(excinfo.value)
mock_client.artifacts.retry_failed.assert_not_called()
async def test_artifact_retry_refusal_projects_tool_error(mcp_call, mock_client) -> None:
"""A synchronous client refusal (rate limit / quota) surfaces as a ToolError."""
def _raise(*_a: Any, **_k: Any) -> Any:
raise RateLimitError("quota exceeded")
mock_client.artifacts.retry_failed = AsyncMock(side_effect=_raise)
with pytest.raises(ToolError):
await mcp_call("studio_retry", {"notebook": NB_ID, "artifact": _ART_FULL})
# ---------------------------------------------------------------------------
# studio_delete
# ---------------------------------------------------------------------------
async def test_studio_delete_confirm_false_preview_shape(mcp_call, mock_client) -> None:
"""``confirm=False`` returns a ``delete_studio_item`` preview and does NOT delete."""
art = _completed_artifact(_ART_FULL, "Podcast 1")
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[art])
mock_client.artifacts.delete = AsyncMock()
result = await mcp_call(
"studio_delete",
{"notebook": NB_ID, "item": "aaaaaaaa-aaaa"},
)
assert result.structured_content["status"] == "needs_confirmation"
preview = result.structured_content["preview"]
assert preview == {
"action": "delete_studio_item",
"notebook_id": NB_ID,
"item_id": _ART_FULL,
"type": "audio",
"title": "Podcast 1",
}
mock_client.artifacts.delete.assert_not_called()
mock_client.notes.delete.assert_not_called()
async def test_studio_delete_note_routes_to_note_delete(mcp_call, mock_client) -> None:
"""A resolved NOTE deletes via the note core (never the artifact delete RPC)."""
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title="My Note", content="body")]
)
mock_client.artifacts.list = AsyncMock(return_value=[])
mock_client.notes.delete = AsyncMock()
mock_client.artifacts.delete = AsyncMock()
result = await mcp_call(
"studio_delete",
{"notebook": NB_ID, "item": _NOTE_ID, "confirm": True},
)
assert result.structured_content == {
"status": "deleted",
"notebook_id": NB_ID,
"item_id": _NOTE_ID,
"type": "note",
"was_note_backed": False,
}
mock_client.notes.delete.assert_awaited_once_with(NB_ID, _NOTE_ID)
mock_client.artifacts.delete.assert_not_called()
async def test_studio_delete_artifact_routes_to_artifact_delete(mcp_call, mock_client) -> None:
"""A resolved ARTIFACT deletes via the artifact delete core (was_note_backed false)."""
art = _completed_artifact(_ART_FULL, "Podcast 1")
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[art])
mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[])
mock_client.artifacts.delete = AsyncMock()
result = await mcp_call(
"studio_delete",
{"notebook": NB_ID, "item": "aaaaaaaa-aaaa", "confirm": True},
)
assert result.structured_content == {
"status": "deleted",
"notebook_id": NB_ID,
"item_id": _ART_FULL,
"type": "audio",
"was_note_backed": False,
}
mock_client.artifacts.delete.assert_awaited_once_with(NB_ID, _ART_FULL)
mock_client.notes.delete.assert_not_called()
async def test_studio_delete_note_backed_mind_map_by_title(mcp_call, mock_client) -> None:
"""A note-backed mind map resolved by title routes through the artifact delete
core, which clears it via ``notes.delete`` (was_note_backed true)."""
mm_id = "mmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm"
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(
return_value=[FakeArtifact(id=mm_id, title="My Map", kind=ArtifactType.MIND_MAP)]
)
mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[FakeMindMap(id=mm_id)])
mock_client.notes.delete = AsyncMock()
mock_client.artifacts.delete = AsyncMock()
result = await mcp_call(
"studio_delete",
{"notebook": NB_ID, "item": "My Map", "confirm": True},
)
assert result.structured_content["was_note_backed"] is True
assert result.structured_content["item_id"] == mm_id
assert result.structured_content["type"] == "mind-map"
mock_client.notes.delete.assert_awaited_once_with(NB_ID, mm_id)
mock_client.artifacts.delete.assert_not_called()
async def test_studio_delete_note_backed_mind_map_by_uppercase_full_uuid(
mcp_call, mock_client
) -> None:
"""An UPPERCASE full UUID in the delete carve-out is normalized to canonical
lowercase before delegating, so the core's CASE-SENSITIVE ``list_note_backed``
probe still finds the note-backed map and clears it via ``notes.delete``.
Mirrors the studio_rename uppercase carve-out fix; regression: without the
``item.lower()`` the probe would miss → ``was_note_backed=False`` / ``unknown``."""
mm_id = "dddddddd-dddd-dddd-dddd-dddddddddddd"
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[FakeMindMap(id=mm_id)])
mock_client.notes.delete = AsyncMock()
mock_client.artifacts.delete = AsyncMock()
result = await mcp_call(
"studio_delete",
{"notebook": NB_ID, "item": mm_id.upper(), "confirm": True},
)
assert result.structured_content["was_note_backed"] is True
assert result.structured_content["type"] == "mind-map"
# The echoed id + the core call use the canonical lowercase form, not the input.
assert result.structured_content["item_id"] == mm_id
mock_client.notes.delete.assert_awaited_once_with(NB_ID, mm_id)
async def test_artifact_delete_absent_full_uuid_is_idempotent(mcp_call, mock_client) -> None:
"""Deleting an already-absent full UUID is a no-error no-op: the merged list
holds neither a note nor an artifact for it, so the full-UUID carve-out routes
to ``artifacts.delete`` (idempotent on missing) without raising."""
absent = "cccccccc-cccc-cccc-cccc-cccccccccccc"
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[])
mock_client.artifacts.delete = AsyncMock()
result = await mcp_call(
"studio_delete",
{"notebook": NB_ID, "item": absent, "confirm": True},
)
assert result.structured_content == {
"status": "deleted",
"notebook_id": NB_ID,
"item_id": absent,
"type": "unknown",
"was_note_backed": False,
}
mock_client.artifacts.delete.assert_awaited_once_with(NB_ID, absent)
mock_client.notes.delete.assert_not_called()
async def test_studio_delete_full_uuid_never_matches_a_note_title(mcp_call, mock_client) -> None:
"""A full UUID is an id-only ref: it must NOT match a note whose *title* happens
to be that UUID. Otherwise the absent-full-UUID idempotent no-op would instead
delete the title-collision note (data loss). It routes to the artifact path."""
uuid_titled_note = "cccccccc-cccc-cccc-cccc-cccccccccccc"
mock_client.notes.list = AsyncMock(
return_value=[FakeNote(id=_NOTE_ID, title=uuid_titled_note, content="body")]
)
mock_client.artifacts.list = AsyncMock(return_value=[])
mock_client.mind_maps.list_note_backed = AsyncMock(return_value=[])
mock_client.artifacts.delete = AsyncMock()
result = await mcp_call(
"studio_delete",
{"notebook": NB_ID, "item": uuid_titled_note, "confirm": True},
)
# Routed to the artifact path (idempotent no-op), NOT the note delete.
assert result.structured_content["type"] == "unknown"
mock_client.notes.delete.assert_not_called()
mock_client.artifacts.delete.assert_awaited_once_with(NB_ID, uuid_titled_note)
async def test_artifact_delete_absent_prefix_projects_tool_error(mcp_call, mock_client) -> None:
"""An absent prefix/title raises NOT_FOUND (never reaching a delete core) —
distinct from the idempotent absent-full-UUID case above."""
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
mock_client.artifacts.delete = AsyncMock()
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_delete",
{"notebook": NB_ID, "item": "No Such Artifact", "confirm": True},
)
assert "NOT_FOUND" in str(excinfo.value)
mock_client.artifacts.delete.assert_not_called()
mock_client.notes.delete.assert_not_called()
# --------------------------------------------------------------------------- #
# Strict IDs-only mode (NOTEBOOKLM_MCP_STRICT_IDS=1) — issue #1808
#
# The studio `item` resolver (resolve_studio_item) and studio_download's explicit
# `artifact_id` are additional artifact/note reference paths that must honor strict
# mode too — a title/prefix is rejected BEFORE the merged studio list is fetched.
# --------------------------------------------------------------------------- #
@pytest.fixture
def _strict_ids(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NOTEBOOKLM_MCP_STRICT_IDS", "1")
async def test_strict_studio_list_item_title_rejected_without_listing(
_strict_ids, mcp_call, mock_client
) -> None:
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
with pytest.raises(ToolError) as excinfo:
await mcp_call("studio_list", {"notebook": NB_ID, "item": "My Podcast"})
assert "NOTEBOOKLM_MCP_STRICT_IDS" in str(excinfo.value)
mock_client.notes.list.assert_not_called()
mock_client.artifacts.list.assert_not_called()
async def test_strict_studio_delete_title_rejected_without_listing(
_strict_ids, mcp_call, mock_client
) -> None:
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
with pytest.raises(ToolError) as excinfo:
await mcp_call("studio_delete", {"notebook": NB_ID, "item": "My Podcast", "confirm": True})
assert "NOTEBOOKLM_MCP_STRICT_IDS" in str(excinfo.value)
mock_client.notes.list.assert_not_called()
mock_client.artifacts.list.assert_not_called()
async def test_strict_studio_rename_title_rejected_without_listing(
_strict_ids, mcp_call, mock_client
) -> None:
mock_client.notes.list = AsyncMock(return_value=[])
mock_client.artifacts.list = AsyncMock(return_value=[])
with pytest.raises(ToolError) as excinfo:
await mcp_call("studio_rename", {"notebook": NB_ID, "item": "My Podcast", "new_title": "X"})
assert "NOTEBOOKLM_MCP_STRICT_IDS" in str(excinfo.value)
mock_client.notes.list.assert_not_called()
mock_client.artifacts.list.assert_not_called()
async def test_strict_studio_download_prefix_artifact_id_rejected(
_strict_ids, mcp_call, mock_client, tmp_path
) -> None:
"""A short `artifact_id` prefix on the explicit path is rejected before listing."""
mock_client.artifacts.list = AsyncMock(return_value=[_AUDIO_ARTIFACT])
with pytest.raises(ToolError) as excinfo:
await mcp_call(
"studio_download",
{
"notebook": NB_ID,
"artifact_type": "audio",
"artifact_id": "abc123",
"path": str(tmp_path / "o.mp3"),
},
)
assert "NOTEBOOKLM_MCP_STRICT_IDS" in str(excinfo.value)
mock_client.artifacts.list.assert_not_called()