项目文件夹

文件
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 行
84 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""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()