From db8da3e84ee342b3c4561144819c95c996f8f3ae Mon Sep 17 00:00:00 2001 From: elean-latedev Date: Wed, 19 Aug 2026 10:26:26 +0200 Subject: [PATCH 1/6] fix(mcp): surface per-platform publish errors and accept empty permalinks Post reading through MCP had two defects reported by an integrator in the same ticket. The failed-post views read the error from post.metadata["error"], a key the API never populates, so posts_get and posts_list_failed fell back to the literal "Unknown error" while posts_list rendered no error at all. The real text lives per platform in PlatformTarget.errorMessage, so _platform_errors now collects it from the failed legs and all three views render it. Legs that later published can still carry a stale errorMessage, so the leg's own status selects it rather than the presence of a message; otherwise a platform that published fine would be reported as an error. posts_get and posts_list also raised a validation error on TikTok posts that published fine, because the API emits platformPostUrl: "" when TikTok confirms a publish without returning an id a permalink can be built from, and the spec declared the field as a URI. PlatformTarget.platformPostUrl now matches the permissive declaration already used elsewhere in the spec. The API emitting "" rather than null is tracked separately; the client has to accept what is already in the responses either way. --- openapi.yaml | 5 +- src/late/mcp/server.py | 28 +++- src/late/models/_generated/models.py | 6 +- tests/test_mcp_post_error_surfacing.py | 216 +++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 tests/test_mcp_post_error_surfacing.py diff --git a/openapi.yaml b/openapi.yaml index 867cb942..d2735e9d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4700,9 +4700,8 @@ components: description: The native post ID on the platform (populated after successful publish) example: "1234567890123456789" platformPostUrl: - type: string - format: uri - description: Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. + type: [string, "null"] + description: Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. Empty when the platform confirmed the publish without returning an id a permalink can be built from (TikTok returns a publish id for some uploads); the TikTok reconcile cron backfills it later. example: "https://twitter.com/acmecorp/status/1234567890123456789" publishedAt: type: string diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py index 8f535f36..7ebd6487 100644 --- a/src/late/mcp/server.py +++ b/src/late/mcp/server.py @@ -509,6 +509,24 @@ def profiles_delete(profile_id: str) -> str: # ============================================================================ +def _platform_errors(post: Any) -> list[str]: + """One "Error (platform): message" line per leg that failed with an error. + + The API never populates post.metadata["error"]; the real text lives per + platform in PlatformTarget.errorMessage. A leg that later published can + still carry a stale errorMessage, so the leg's own status is what selects + it, not the presence of a message. + """ + errors = [] + for target in post.platforms or []: + if target.status != "failed": + continue + message = (target.errorMessage or "").strip() + if message: + errors.append(f"Error ({target.platform or '?'}): {message}") + return errors + + @tool_def("posts_list") def posts_list(status: str = "", limit: int = 10) -> str: client = _get_client() @@ -532,6 +550,7 @@ def posts_list(status: str = "", limit: int = 10) -> str: status = post.status.value if post.status else "unknown" lines.append(f"- [{status}] {content_preview}") lines.append(f" Platforms: {platforms} | ID: {post.field_id}") + lines.extend(f" {error}" for error in _platform_errors(post)) return "\n".join(lines) @@ -562,8 +581,7 @@ def posts_get(post_id: str) -> str: if hasattr(post, "publishedAt") and post.publishedAt: lines.append(f"Published at: {post.publishedAt}") - if post.metadata and post.metadata.get("error"): - lines.append(f"Error: {post.metadata['error']}") + lines.extend(_platform_errors(post)) return "\n".join(lines) @@ -829,10 +847,12 @@ def posts_list_failed(limit: int = 10) -> str: content = post.content or "" content_preview = content[:50] + "..." if len(content) > 50 else content platforms = ", ".join(t.platform or "?" for t in (post.platforms or [])) - error = post.metadata.get("error", "Unknown error") if post.metadata else "Unknown error" lines.append(f"- {content_preview}") lines.append(f" Platforms: {platforms} | ID: {post.field_id}") - lines.append(f" Error: {error}") + # This view exists to show why posts failed, so it always carries an + # error line even when no leg recorded a message. + errors = _platform_errors(post) or ["Error: Unknown error"] + lines.extend(f" {error}" for error in errors) lines.append("") return "\n".join(lines) diff --git a/src/late/models/_generated/models.py b/src/late/models/_generated/models.py index 5d6cb44b..d0830cf9 100644 --- a/src/late/models/_generated/models.py +++ b/src/late/models/_generated/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-08-18T11:01:45+00:00 +# timestamp: 2026-08-19T08:24:32+00:00 from __future__ import annotations @@ -10796,11 +10796,11 @@ class PlatformTarget(BaseModel): The native post ID on the platform (populated after successful publish) """ platformPostUrl: Annotated[ - AnyUrl | None, + str | None, Field(examples=["https://twitter.com/acmecorp/status/1234567890123456789"]), ] = None """ - Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. + Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. Empty when the platform confirmed the publish without returning an id a permalink can be built from (TikTok returns a publish id for some uploads); the TikTok reconcile cron backfills it later. """ publishedAt: AwareDatetime | None = None """ diff --git a/tests/test_mcp_post_error_surfacing.py b/tests/test_mcp_post_error_surfacing.py new file mode 100644 index 00000000..21fb7ac2 --- /dev/null +++ b/tests/test_mcp_post_error_surfacing.py @@ -0,0 +1,216 @@ +""" +Regression tests for the MCP post-reading tools (Crisp session_6e4c63b7). + +An integrator reported two defects in the same ticket: + +1. The failed-post views read the error from ``post.metadata["error"]``, a key + the API never populates, and fell back to the literal ``"Unknown error"``. + The real text lives per platform in ``PlatformTarget.errorMessage``. + ``posts_list`` did not render an error at all, not even the fallback. +2. ``posts_get``/``posts_list`` raised ``1 validation error for + PostGetResponse`` on TikTok posts that published fine, because the API + emits ``platformPostUrl: ""`` when TikTok confirms a publish without a + numeric video id, and the generated model declared the field as a URI. + +The payloads below are the real shapes from the reporting account +(userId 6a4fe17d8adb6036cd5c37c6): a TikTok post failed with the platform's +quota message, and a TikTok post published with a ``p_pub_url~v2.`` publish id +and an empty permalink. + +The legs that already published but still carry a stale ``errorMessage`` are +deliberate: production posts do carry that combination, and a leg that +published must never be reported as an error. +""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest + +import late.client.base as client_base +from late import Late +from late.mcp import server as mcp_server + +QUOTA_ERROR = "Daily active user quota reached." + +# Failed TikTok post. metadata carries billing bookkeeping and no "error" key, +# exactly as the API returns it. +FAILED_POST: dict[str, Any] = { + "_id": "6a8392cdc6abe639aadbcc21", + "content": "Free Content DNA report - crezio.ai/report", + "status": "failed", + "metadata": {"usageCounted": True, "usageRefunded": True}, + "platforms": [ + { + "platform": "tiktok", + "accountId": "6a7e062a77555aae017c9617", + "status": "failed", + "errorMessage": QUOTA_ERROR, + "errorCategory": "user_abuse", + "errorSource": "platform", + } + ], +} + +# Published TikTok post whose permalink could not be built: TikTok returned a +# publish id instead of a numeric video id. +EMPTY_URL_POST: dict[str, Any] = { + "_id": "6a7e23169598cfb3119bb578", + "content": "Nothing goes viral. Somebody built it to.", + "status": "published", + "platforms": [ + { + "platform": "tiktok", + "accountId": "6a7e062a77555aae017c9617", + "status": "published", + "platformPostId": "p_pub_url~v2.7673609262345750542", + "platformPostUrl": "", + } + ], +} + +# Partial failure: one leg published (carrying a stale errorMessage from an +# earlier attempt) and one leg genuinely failed. +MIXED_POST: dict[str, Any] = { + "_id": "691a85709ed078ff2f35bd16", + "content": "Cross-posted launch announcement", + "status": "failed", + "platforms": [ + { + "platform": "instagram", + "accountId": "a1", + "status": "published", + "errorMessage": "Publishing failed due to timeout or max retries reached", + "platformPostUrl": "https://www.instagram.com/reel/ABC123xyz/", + }, + { + "platform": "linkedin", + "accountId": "a2", + "status": "failed", + "errorMessage": "Publishing failed due to timeout or max retries reached", + }, + ], +} + +POSTS_BY_ID = {p["_id"]: p for p in (FAILED_POST, EMPTY_URL_POST, MIXED_POST)} + + +class FakeZernioAPI: + """In-memory Zernio API serving the payloads above.""" + + def handler(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + + if request.method == "GET" and path == "/api/v1/posts": + status = request.url.params.get("status") + posts = [p for p in POSTS_BY_ID.values() if not status or p["status"] == status] + return self._json({"posts": posts, "pagination": {}}) + + if request.method == "GET" and path.startswith("/api/v1/posts/"): + post = POSTS_BY_ID.get(path.rsplit("/", 1)[-1]) + if post is not None: + return self._json({"post": post}) + + return httpx.Response(404, json={"error": f"unexpected: {path}"}) + + @staticmethod + def _json(body: dict[str, Any]) -> httpx.Response: + return httpx.Response( + 200, + content=json.dumps(body), + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture() +def fake_api(monkeypatch: pytest.MonkeyPatch) -> FakeZernioAPI: + """Route all SDK HTTP traffic to the fake API and wire up the MCP client.""" + api = FakeZernioAPI() + real_client = httpx.Client + + def patched_client(**kwargs: Any) -> httpx.Client: + kwargs["transport"] = httpx.MockTransport(api.handler) + return real_client(**kwargs) + + monkeypatch.setattr(client_base.httpx, "Client", patched_client) + monkeypatch.setattr(mcp_server, "_get_client", lambda: Late(api_key="test-key")) + return api + + +@pytest.mark.usefixtures("fake_api") +class TestEmptyPermalinkIsAccepted: + """Defect 2: an empty permalink must not make the post unreadable.""" + + def test_posts_get_returns_the_post(self) -> None: + result = mcp_server.posts_get("6a7e23169598cfb3119bb578") + + assert "validation error" not in result + assert "6a7e23169598cfb3119bb578" in result + assert "published" in result + + def test_posts_list_returns_the_post(self) -> None: + result = mcp_server.posts_list() + + assert "validation error" not in result + assert "6a7e23169598cfb3119bb578" in result + + def test_model_accepts_empty_platform_post_url(self) -> None: + """The generated model itself, so a spec regression fails here first.""" + from late.models import PlatformTarget + + target = PlatformTarget.model_validate( + {"platform": "tiktok", "status": "published", "platformPostUrl": ""} + ) + + assert target.platformPostUrl == "" + + def test_model_still_accepts_a_real_permalink(self) -> None: + from late.models import PlatformTarget + + url = "https://www.tiktok.com/@crezio.ai/video/7675444697107614990" + target = PlatformTarget.model_validate( + {"platform": "tiktok", "status": "published", "platformPostUrl": url} + ) + + assert str(target.platformPostUrl) == url + + +@pytest.mark.usefixtures("fake_api") +class TestPlatformErrorIsSurfaced: + """Defect 1: the real error text lives per platform, not in metadata.""" + + def test_posts_get_shows_the_platform_error(self) -> None: + result = mcp_server.posts_get("6a8392cdc6abe639aadbcc21") + + assert QUOTA_ERROR in result + assert "Unknown error" not in result + + def test_posts_list_failed_shows_the_platform_error(self) -> None: + result = mcp_server.posts_list_failed() + + assert QUOTA_ERROR in result + assert "Unknown error" not in result + + def test_posts_list_shows_the_platform_error(self) -> None: + """posts_list rendered no error at all, not even the fallback.""" + result = mcp_server.posts_list(status="failed") + + assert QUOTA_ERROR in result + + def test_posts_list_is_quiet_for_healthy_posts(self) -> None: + result = mcp_server.posts_list(status="published") + + assert "Error" not in result + + def test_error_is_attributed_to_the_failing_platform(self) -> None: + """A leg that published must not be reported as an error.""" + result = mcp_server.posts_get("691a85709ed078ff2f35bd16") + + error_lines = [line for line in result.splitlines() if "Error" in line] + assert error_lines, "expected the failed leg to be reported" + joined = "\n".join(error_lines) + assert "linkedin" in joined + assert "instagram" not in joined From bacf477383fe04d9505cf3fc7bce7741fd0c0568 Mon Sep 17 00:00:00 2001 From: elean-latedev Date: Wed, 19 Aug 2026 13:13:46 +0200 Subject: [PATCH 2/6] fix(mcp): surface cancelled-leg errors in post views Account-disconnect cleanup cancels a post's legs and writes the reason onto them ('Account "X" was disconnected'), so a failed post whose only leg was cancelled rendered "Unknown error" while the document held the answer. That is the same complaint the ticket opened with, one status short of fixed. The leg status enum has six values, and they partition into in-flight (pending, processing, uploading), success (published), and terminal non-success (failed, cancelled). The error views want that third set, so the guard now selects it rather than failed alone. In-flight legs stay excluded on purpose: every reset path clears their errorMessage, so anything left on one is stale. The "Unknown error" fallback stays too, since failed posts whose only leg is pending or stale-published still reach it. Drafts and scheduled posts can hold a cancelled leg, because retry never resets one, so unfiltered posts_list now renders those as well. Deliberate, and pinned by a test. Refs Crisp session_6e4c63b7. --- src/late/mcp/server.py | 11 ++- tests/test_mcp_post_error_surfacing.py | 116 ++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py index 7ebd6487..0bad3fe2 100644 --- a/src/late/mcp/server.py +++ b/src/late/mcp/server.py @@ -510,16 +510,23 @@ def profiles_delete(profile_id: str) -> str: def _platform_errors(post: Any) -> list[str]: - """One "Error (platform): message" line per leg that failed with an error. + """One "Error (platform): message" line per leg that terminally did not publish. The API never populates post.metadata["error"]; the real text lives per platform in PlatformTarget.errorMessage. A leg that later published can still carry a stale errorMessage, so the leg's own status is what selects it, not the presence of a message. + + Both terminal non-success statuses count. Cancelled legs matter because + account-disconnect cleanup writes the only actionable reason onto them + ('Account "X" was disconnected'), so a failed post whose single leg was + cancelled would otherwise read "Unknown error". In-flight legs (pending, + processing, uploading) are excluded: every reset path clears their + errorMessage, so whatever is left on one is stale. """ errors = [] for target in post.platforms or []: - if target.status != "failed": + if target.status not in ("failed", "cancelled"): continue message = (target.errorMessage or "").strip() if message: diff --git a/tests/test_mcp_post_error_surfacing.py b/tests/test_mcp_post_error_surfacing.py index 21fb7ac2..c2b30cc0 100644 --- a/tests/test_mcp_post_error_surfacing.py +++ b/tests/test_mcp_post_error_surfacing.py @@ -20,6 +20,10 @@ The legs that already published but still carry a stale ``errorMessage`` are deliberate: production posts do carry that combination, and a leg that published must never be reported as an error. + +Cancelled legs are covered too. Account-disconnect cleanup writes the only +actionable reason onto them, so a failed post whose single leg was cancelled +used to read "Unknown error" while the document held the answer. """ from __future__ import annotations @@ -95,7 +99,75 @@ ], } -POSTS_BY_ID = {p["_id"]: p for p in (FAILED_POST, EMPTY_URL_POST, MIXED_POST)} +# Failed post whose only leg was cancelled by account-disconnect cleanup. The +# cancelled leg carries the one actionable reason, so skipping it leaves the +# whole post reading "Unknown error". +CANCELLED_POST: dict[str, Any] = { + "_id": "6a74056fa22ac1afb451b7da", + "content": "Weekly roundup", + "status": "failed", + "platforms": [ + { + "platform": "linkedin", + "accountId": "a3", + "status": "cancelled", + "errorMessage": 'Account "Edward Hollis" was disconnected', + "errorCategory": "account_issue", + "errorSource": "user", + } + ], +} + +# A cancelled leg next to failed ones: the disconnect is the actionable line, +# the timeouts are the generic ones. Neither may displace the other. +MIXED_CANCELLED_POST: dict[str, Any] = { + "_id": "698f117bdc985b51e808e3e4", + "content": "Campaign launch", + "status": "failed", + "platforms": [ + { + "platform": "instagram", + "accountId": "a4", + "status": "failed", + "errorMessage": "Publishing failed due to timeout or max retries reached", + }, + { + "platform": "facebook", + "accountId": "a5", + "status": "cancelled", + "errorMessage": 'Account "The clam Qalb" was disconnected', + }, + ], +} + +# A draft still holding a cancelled leg: retry never resets cancelled legs, so +# unfiltered posts_list renders this one too. Deliberate, and pinned here. +DRAFT_WITH_CANCELLED_LEG: dict[str, Any] = { + "_id": "69047ddcbb4db2cc1794478d", + "content": "Unfinished draft", + "status": "draft", + "platforms": [ + {"platform": "youtube", "accountId": "a6", "status": "pending"}, + { + "platform": "instagram", + "accountId": "a7", + "status": "cancelled", + "errorMessage": 'Account "Cody bailey" was disconnected', + }, + ], +} + +POSTS_BY_ID = { + p["_id"]: p + for p in ( + FAILED_POST, + EMPTY_URL_POST, + MIXED_POST, + CANCELLED_POST, + MIXED_CANCELLED_POST, + DRAFT_WITH_CANCELLED_LEG, + ) +} class FakeZernioAPI: @@ -214,3 +286,45 @@ def test_error_is_attributed_to_the_failing_platform(self) -> None: joined = "\n".join(error_lines) assert "linkedin" in joined assert "instagram" not in joined + + +@pytest.mark.usefixtures("fake_api") +class TestCancelledLegIsSurfaced: + """Cancelled is the other terminal non-success status. + + Account-disconnect cleanup writes the only actionable reason onto the + cancelled leg, so a post whose single leg was cancelled used to read + "Unknown error" while the document held the answer. + """ + + def test_posts_list_failed_shows_the_cancelled_reason(self) -> None: + result = mcp_server.posts_list_failed() + + assert "Edward Hollis" in result + assert "Unknown error" not in result + + def test_posts_get_shows_the_cancelled_reason(self) -> None: + result = mcp_server.posts_get("6a74056fa22ac1afb451b7da") + + assert "Edward Hollis" in result + assert "linkedin" in result + + def test_cancelled_reason_does_not_displace_the_failed_ones(self) -> None: + result = mcp_server.posts_get("698f117bdc985b51e808e3e4") + + assert "The clam Qalb" in result + assert "timeout or max retries reached" in result + + def test_draft_keeps_rendering_its_cancelled_leg(self) -> None: + """Retry never resets cancelled legs, so drafts carry them. Declared.""" + result = mcp_server.posts_list(status="draft") + + assert "Cody bailey" in result + + def test_in_flight_legs_stay_silent(self) -> None: + """The draft's pending youtube leg must not produce an error line.""" + result = mcp_server.posts_get("69047ddcbb4db2cc1794478d") + + error_lines = [line for line in result.splitlines() if "Error" in line] + assert len(error_lines) == 1 + assert "youtube" not in error_lines[0] From 1fae2e7242468321b337ff660ddda1c79a7a2582 Mon Sep 17 00:00:00 2001 From: elean-latedev Date: Wed, 19 Aug 2026 13:22:42 +0200 Subject: [PATCH 3/6] docs(mcp): say "platform" instead of "leg" in the post error comments "Leg" is not a word this codebase uses anywhere else, so the comments explaining why _platform_errors selects what it selects were the hardest part of the change to read. A post holds one entry in platforms[] per target platform, and that is what the comments now call it. Comments, docstrings and test names only. No behaviour change. --- src/late/mcp/server.py | 33 +++++++++------- tests/test_mcp_post_error_surfacing.py | 52 +++++++++++++------------- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py index 0bad3fe2..796cab88 100644 --- a/src/late/mcp/server.py +++ b/src/late/mcp/server.py @@ -510,19 +510,24 @@ def profiles_delete(profile_id: str) -> str: def _platform_errors(post: Any) -> list[str]: - """One "Error (platform): message" line per leg that terminally did not publish. - - The API never populates post.metadata["error"]; the real text lives per - platform in PlatformTarget.errorMessage. A leg that later published can - still carry a stale errorMessage, so the leg's own status is what selects - it, not the presence of a message. - - Both terminal non-success statuses count. Cancelled legs matter because - account-disconnect cleanup writes the only actionable reason onto them - ('Account "X" was disconnected'), so a failed post whose single leg was - cancelled would otherwise read "Unknown error". In-flight legs (pending, - processing, uploading) are excluded: every reset path clears their - errorMessage, so whatever is left on one is stale. + """One "Error (platform): message" line per platform that did not publish. + + A post holds one entry in post.platforms[] per target platform, and each + entry tracks its own status and errorMessage. The API never populates + post.metadata["error"], so that is where the real text lives. + + An entry that published can still carry an errorMessage left over from an + earlier attempt, so the entry's own status decides whether it counts, not + whether it has a message. + + Two statuses count, the two that mean "this platform is done and did not + publish": failed and cancelled. Cancelled matters because + account-disconnect cleanup writes the only actionable reason onto that + entry ('Account "X" was disconnected'), so a failed post targeting one + platform that got cancelled would otherwise read "Unknown error". + + The in-progress statuses (pending, processing, uploading) are excluded: + every reset path clears errorMessage, so anything still on one is stale. """ errors = [] for target in post.platforms or []: @@ -857,7 +862,7 @@ def posts_list_failed(limit: int = 10) -> str: lines.append(f"- {content_preview}") lines.append(f" Platforms: {platforms} | ID: {post.field_id}") # This view exists to show why posts failed, so it always carries an - # error line even when no leg recorded a message. + # error line even when no platform recorded a message. errors = _platform_errors(post) or ["Error: Unknown error"] lines.extend(f" {error}" for error in errors) lines.append("") diff --git a/tests/test_mcp_post_error_surfacing.py b/tests/test_mcp_post_error_surfacing.py index c2b30cc0..e03a876e 100644 --- a/tests/test_mcp_post_error_surfacing.py +++ b/tests/test_mcp_post_error_surfacing.py @@ -17,13 +17,15 @@ quota message, and a TikTok post published with a ``p_pub_url~v2.`` publish id and an empty permalink. -The legs that already published but still carry a stale ``errorMessage`` are -deliberate: production posts do carry that combination, and a leg that +A post holds one entry in ``platforms[]`` per target platform. The entries +below that already published but still carry a stale ``errorMessage`` are +deliberate: production posts do carry that combination, and a platform that published must never be reported as an error. -Cancelled legs are covered too. Account-disconnect cleanup writes the only -actionable reason onto them, so a failed post whose single leg was cancelled -used to read "Unknown error" while the document held the answer. +Cancelled platforms are covered too. Account-disconnect cleanup writes the +only actionable reason onto that entry, so a failed post targeting a single +platform that got cancelled used to read "Unknown error" while the document +held the answer. """ from __future__ import annotations @@ -76,8 +78,8 @@ ], } -# Partial failure: one leg published (carrying a stale errorMessage from an -# earlier attempt) and one leg genuinely failed. +# Partial failure: one platform published (carrying an errorMessage left over +# from an earlier attempt) and one platform genuinely failed. MIXED_POST: dict[str, Any] = { "_id": "691a85709ed078ff2f35bd16", "content": "Cross-posted launch announcement", @@ -99,9 +101,9 @@ ], } -# Failed post whose only leg was cancelled by account-disconnect cleanup. The -# cancelled leg carries the one actionable reason, so skipping it leaves the -# whole post reading "Unknown error". +# Failed post whose only platform was cancelled by account-disconnect cleanup. +# That entry carries the one actionable reason, so skipping it leaves the whole +# post reading "Unknown error". CANCELLED_POST: dict[str, Any] = { "_id": "6a74056fa22ac1afb451b7da", "content": "Weekly roundup", @@ -118,8 +120,8 @@ ], } -# A cancelled leg next to failed ones: the disconnect is the actionable line, -# the timeouts are the generic ones. Neither may displace the other. +# A cancelled platform next to failed ones: the disconnect is the actionable +# line, the timeouts are the generic ones. Neither may displace the other. MIXED_CANCELLED_POST: dict[str, Any] = { "_id": "698f117bdc985b51e808e3e4", "content": "Campaign launch", @@ -140,9 +142,9 @@ ], } -# A draft still holding a cancelled leg: retry never resets cancelled legs, so -# unfiltered posts_list renders this one too. Deliberate, and pinned here. -DRAFT_WITH_CANCELLED_LEG: dict[str, Any] = { +# A draft still holding a cancelled platform: retry never resets a cancelled +# entry, so unfiltered posts_list renders this one too. Deliberate, pinned here. +DRAFT_WITH_CANCELLED_PLATFORM: dict[str, Any] = { "_id": "69047ddcbb4db2cc1794478d", "content": "Unfinished draft", "status": "draft", @@ -165,7 +167,7 @@ MIXED_POST, CANCELLED_POST, MIXED_CANCELLED_POST, - DRAFT_WITH_CANCELLED_LEG, + DRAFT_WITH_CANCELLED_PLATFORM, ) } @@ -278,23 +280,23 @@ def test_posts_list_is_quiet_for_healthy_posts(self) -> None: assert "Error" not in result def test_error_is_attributed_to_the_failing_platform(self) -> None: - """A leg that published must not be reported as an error.""" + """A platform that published must not be reported as an error.""" result = mcp_server.posts_get("691a85709ed078ff2f35bd16") error_lines = [line for line in result.splitlines() if "Error" in line] - assert error_lines, "expected the failed leg to be reported" + assert error_lines, "expected the failed platform to be reported" joined = "\n".join(error_lines) assert "linkedin" in joined assert "instagram" not in joined @pytest.mark.usefixtures("fake_api") -class TestCancelledLegIsSurfaced: +class TestCancelledPlatformIsSurfaced: """Cancelled is the other terminal non-success status. Account-disconnect cleanup writes the only actionable reason onto the - cancelled leg, so a post whose single leg was cancelled used to read - "Unknown error" while the document held the answer. + cancelled entry, so a post targeting a single platform that got cancelled + used to read "Unknown error" while the document held the answer. """ def test_posts_list_failed_shows_the_cancelled_reason(self) -> None: @@ -315,14 +317,14 @@ def test_cancelled_reason_does_not_displace_the_failed_ones(self) -> None: assert "The clam Qalb" in result assert "timeout or max retries reached" in result - def test_draft_keeps_rendering_its_cancelled_leg(self) -> None: - """Retry never resets cancelled legs, so drafts carry them. Declared.""" + def test_draft_keeps_rendering_its_cancelled_platform(self) -> None: + """Retry never resets a cancelled entry, so drafts carry them. Declared.""" result = mcp_server.posts_list(status="draft") assert "Cody bailey" in result - def test_in_flight_legs_stay_silent(self) -> None: - """The draft's pending youtube leg must not produce an error line.""" + def test_in_progress_platforms_stay_silent(self) -> None: + """The draft's pending youtube entry must not produce an error line.""" result = mcp_server.posts_get("69047ddcbb4db2cc1794478d") error_lines = [line for line in result.splitlines() if "Error" in line] From 155850f2b0d752f9a9a413714e6e5b2c982e4ca6 Mon Sep 17 00:00:00 2001 From: elean-latedev Date: Wed, 19 Aug 2026 13:40:24 +0200 Subject: [PATCH 4/6] revert(mcp): drop the spec and model changes from this PR The platformPostUrl declaration is being fixed at the source, in the API repo's public/openapi.yaml, and reaches this repo through the regular "chore: regenerate from OpenAPI spec" pull. Carrying a hand-made copy of it here would collide with that regeneration for no gain. This PR therefore keeps only what is genuinely SDK-side: the MCP post views reading the error from platforms[] instead of the metadata key the API never populates. The empty-permalink coverage goes with the declaration it depends on. Those tests assert that PlatformTarget accepts platformPostUrl: "", which stays false until the regenerated model lands, so they follow in their own PR once the spec pull has gone through. --- openapi.yaml | 5 +- src/late/models/_generated/models.py | 6 +-- tests/test_mcp_post_error_surfacing.py | 74 ++++++-------------------- 3 files changed, 22 insertions(+), 63 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index cb984003..95104fcf 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4700,8 +4700,9 @@ components: description: The native post ID on the platform (populated after successful publish) example: "1234567890123456789" platformPostUrl: - type: [string, "null"] - description: Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. Empty when the platform confirmed the publish without returning an id a permalink can be built from (TikTok returns a publish id for some uploads); the TikTok reconcile cron backfills it later. + type: string + format: uri + description: Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. example: "https://twitter.com/acmecorp/status/1234567890123456789" publishedAt: type: string diff --git a/src/late/models/_generated/models.py b/src/late/models/_generated/models.py index 39f0486f..84bf5e78 100644 --- a/src/late/models/_generated/models.py +++ b/src/late/models/_generated/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-08-19T11:30:32+00:00 +# timestamp: 2026-08-19T10:17:15+00:00 from __future__ import annotations @@ -10796,11 +10796,11 @@ class PlatformTarget(BaseModel): The native post ID on the platform (populated after successful publish) """ platformPostUrl: Annotated[ - str | None, + AnyUrl | None, Field(examples=["https://twitter.com/acmecorp/status/1234567890123456789"]), ] = None """ - Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. Empty when the platform confirmed the publish without returning an id a permalink can be built from (TikTok returns a publish id for some uploads); the TikTok reconcile cron backfills it later. + Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. """ publishedAt: AwareDatetime | None = None """ diff --git a/tests/test_mcp_post_error_surfacing.py b/tests/test_mcp_post_error_surfacing.py index e03a876e..ceacf2db 100644 --- a/tests/test_mcp_post_error_surfacing.py +++ b/tests/test_mcp_post_error_surfacing.py @@ -1,21 +1,18 @@ """ Regression tests for the MCP post-reading tools (Crisp session_6e4c63b7). -An integrator reported two defects in the same ticket: - -1. The failed-post views read the error from ``post.metadata["error"]``, a key - the API never populates, and fell back to the literal ``"Unknown error"``. - The real text lives per platform in ``PlatformTarget.errorMessage``. - ``posts_list`` did not render an error at all, not even the fallback. -2. ``posts_get``/``posts_list`` raised ``1 validation error for - PostGetResponse`` on TikTok posts that published fine, because the API - emits ``platformPostUrl: ""`` when TikTok confirms a publish without a - numeric video id, and the generated model declared the field as a URI. +The failed-post views read the error from ``post.metadata["error"]``, a key +the API never populates, and fell back to the literal ``"Unknown error"``. The +real text lives per platform in ``PlatformTarget.errorMessage``. ``posts_list`` +did not render an error at all, not even the fallback. The payloads below are the real shapes from the reporting account -(userId 6a4fe17d8adb6036cd5c37c6): a TikTok post failed with the platform's -quota message, and a TikTok post published with a ``p_pub_url~v2.`` publish id -and an empty permalink. +(userId 6a4fe17d8adb6036cd5c37c6). + +The same ticket reported a second defect, ``posts_get`` raising a validation +error on TikTok posts whose ``platformPostUrl`` is an empty string. That one is +fixed in the OpenAPI spec and arrives here with the next model regeneration, so +its coverage lands in a follow-up. A post holds one entry in ``platforms[]`` per target platform. The entries below that already published but still carry a stale ``errorMessage`` are @@ -61,10 +58,9 @@ ], } -# Published TikTok post whose permalink could not be built: TikTok returned a -# publish id instead of a numeric video id. -EMPTY_URL_POST: dict[str, Any] = { - "_id": "6a7e23169598cfb3119bb578", +# Healthy published post: nothing here may produce an error line. +PUBLISHED_POST: dict[str, Any] = { + "_id": "6a841714e25e28de95dd35d3", "content": "Nothing goes viral. Somebody built it to.", "status": "published", "platforms": [ @@ -72,8 +68,8 @@ "platform": "tiktok", "accountId": "6a7e062a77555aae017c9617", "status": "published", - "platformPostId": "p_pub_url~v2.7673609262345750542", - "platformPostUrl": "", + "platformPostId": "7675444697107614990", + "platformPostUrl": "https://www.tiktok.com/@crezio.ai/video/7675444697107614990", } ], } @@ -163,7 +159,7 @@ p["_id"]: p for p in ( FAILED_POST, - EMPTY_URL_POST, + PUBLISHED_POST, MIXED_POST, CANCELLED_POST, MIXED_CANCELLED_POST, @@ -214,44 +210,6 @@ def patched_client(**kwargs: Any) -> httpx.Client: return api -@pytest.mark.usefixtures("fake_api") -class TestEmptyPermalinkIsAccepted: - """Defect 2: an empty permalink must not make the post unreadable.""" - - def test_posts_get_returns_the_post(self) -> None: - result = mcp_server.posts_get("6a7e23169598cfb3119bb578") - - assert "validation error" not in result - assert "6a7e23169598cfb3119bb578" in result - assert "published" in result - - def test_posts_list_returns_the_post(self) -> None: - result = mcp_server.posts_list() - - assert "validation error" not in result - assert "6a7e23169598cfb3119bb578" in result - - def test_model_accepts_empty_platform_post_url(self) -> None: - """The generated model itself, so a spec regression fails here first.""" - from late.models import PlatformTarget - - target = PlatformTarget.model_validate( - {"platform": "tiktok", "status": "published", "platformPostUrl": ""} - ) - - assert target.platformPostUrl == "" - - def test_model_still_accepts_a_real_permalink(self) -> None: - from late.models import PlatformTarget - - url = "https://www.tiktok.com/@crezio.ai/video/7675444697107614990" - target = PlatformTarget.model_validate( - {"platform": "tiktok", "status": "published", "platformPostUrl": url} - ) - - assert str(target.platformPostUrl) == url - - @pytest.mark.usefixtures("fake_api") class TestPlatformErrorIsSurfaced: """Defect 1: the real error text lives per platform, not in metadata.""" From 944304107b444334f40c66793661b1bbfb9277ed Mon Sep 17 00:00:00 2001 From: elean-latedev Date: Wed, 19 Aug 2026 14:09:51 +0200 Subject: [PATCH 5/6] test(mcp): cover the empty permalink now that the regenerated model allows it The spec fix landed in the API repo and reached this repo through the regular regeneration, so PlatformTarget.platformPostUrl is str | None and the payload the API actually sends for some TikTok publishes parses again. These tests pin that: posts_get and posts_list must return a post whose permalink is an empty string, and the model must keep rejecting nothing while still accepting a real URL. A spec regression that reintroduces format: uri fails here rather than in a customer's MCP client. --- tests/test_mcp_post_error_surfacing.py | 77 +++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/tests/test_mcp_post_error_surfacing.py b/tests/test_mcp_post_error_surfacing.py index ceacf2db..6dc6e1aa 100644 --- a/tests/test_mcp_post_error_surfacing.py +++ b/tests/test_mcp_post_error_surfacing.py @@ -1,19 +1,22 @@ """ Regression tests for the MCP post-reading tools (Crisp session_6e4c63b7). -The failed-post views read the error from ``post.metadata["error"]``, a key -the API never populates, and fell back to the literal ``"Unknown error"``. The -real text lives per platform in ``PlatformTarget.errorMessage``. ``posts_list`` -did not render an error at all, not even the fallback. +An integrator reported two defects in the same ticket: + +1. The failed-post views read the error from ``post.metadata["error"]``, a key + the API never populates, and fell back to the literal ``"Unknown error"``. + The real text lives per platform in ``PlatformTarget.errorMessage``. + ``posts_list`` did not render an error at all, not even the fallback. +2. ``posts_get``/``posts_list`` raised ``1 validation error for + PostGetResponse`` on TikTok posts that published fine, because the API + emits ``platformPostUrl: ""`` when TikTok confirms a publish without a + numeric video id, and the spec declared the field as a URI. Fixed in the + OpenAPI spec, so these tests guard the regenerated model against a + regression in it. The payloads below are the real shapes from the reporting account (userId 6a4fe17d8adb6036cd5c37c6). -The same ticket reported a second defect, ``posts_get`` raising a validation -error on TikTok posts whose ``platformPostUrl`` is an empty string. That one is -fixed in the OpenAPI spec and arrives here with the next model regeneration, so -its coverage lands in a follow-up. - A post holds one entry in ``platforms[]`` per target platform. The entries below that already published but still carry a stale ``errorMessage`` are deliberate: production posts do carry that combination, and a platform that @@ -74,6 +77,23 @@ ], } +# Published TikTok post whose permalink could not be built: TikTok returned a +# publish id instead of a numeric video id, so the API sends an empty string. +EMPTY_URL_POST: dict[str, Any] = { + "_id": "6a7e23169598cfb3119bb578", + "content": "Nothing goes viral. Somebody built it to.", + "status": "published", + "platforms": [ + { + "platform": "tiktok", + "accountId": "6a7e062a77555aae017c9617", + "status": "published", + "platformPostId": "p_pub_url~v2.7673609262345750542", + "platformPostUrl": "", + } + ], +} + # Partial failure: one platform published (carrying an errorMessage left over # from an earlier attempt) and one platform genuinely failed. MIXED_POST: dict[str, Any] = { @@ -160,6 +180,7 @@ for p in ( FAILED_POST, PUBLISHED_POST, + EMPTY_URL_POST, MIXED_POST, CANCELLED_POST, MIXED_CANCELLED_POST, @@ -210,6 +231,44 @@ def patched_client(**kwargs: Any) -> httpx.Client: return api +@pytest.mark.usefixtures("fake_api") +class TestEmptyPermalinkIsAccepted: + """Defect 2: an empty permalink must not make the post unreadable.""" + + def test_posts_get_returns_the_post(self) -> None: + result = mcp_server.posts_get("6a7e23169598cfb3119bb578") + + assert "validation error" not in result + assert "6a7e23169598cfb3119bb578" in result + assert "published" in result + + def test_posts_list_returns_the_post(self) -> None: + result = mcp_server.posts_list() + + assert "validation error" not in result + assert "6a7e23169598cfb3119bb578" in result + + def test_model_accepts_empty_platform_post_url(self) -> None: + """The generated model itself, so a spec regression fails here first.""" + from late.models import PlatformTarget + + target = PlatformTarget.model_validate( + {"platform": "tiktok", "status": "published", "platformPostUrl": ""} + ) + + assert target.platformPostUrl == "" + + def test_model_still_accepts_a_real_permalink(self) -> None: + from late.models import PlatformTarget + + url = "https://www.tiktok.com/@crezio.ai/video/7675444697107614990" + target = PlatformTarget.model_validate( + {"platform": "tiktok", "status": "published", "platformPostUrl": url} + ) + + assert str(target.platformPostUrl) == url + + @pytest.mark.usefixtures("fake_api") class TestPlatformErrorIsSurfaced: """Defect 1: the real error text lives per platform, not in metadata.""" From 5db3b1ec0cd5bb3df052012a3efa7e16887e4ab3 Mon Sep 17 00:00:00 2001 From: elean-latedev Date: Wed, 19 Aug 2026 14:14:58 +0200 Subject: [PATCH 6/6] test(mcp): make the permalink guard actually discriminate The test asserted that a real permalink still parses, using a TikTok URL that AnyUrl leaves untouched, so it passed with and without the fix and guarded nothing. A bare-origin URL is the case that separates them: AnyUrl renders "https://example.com" back as "https://example.com/", so before the fix the client handed consumers a permalink the platform never sent. The test now pins that the value comes back byte for byte, and fails against the previous model. --- tests/test_mcp_post_error_surfacing.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_mcp_post_error_surfacing.py b/tests/test_mcp_post_error_surfacing.py index 6dc6e1aa..61526a33 100644 --- a/tests/test_mcp_post_error_surfacing.py +++ b/tests/test_mcp_post_error_surfacing.py @@ -258,15 +258,22 @@ def test_model_accepts_empty_platform_post_url(self) -> None: assert target.platformPostUrl == "" - def test_model_still_accepts_a_real_permalink(self) -> None: + def test_a_real_permalink_comes_back_exactly_as_the_api_sent_it(self) -> None: + """No normalization: AnyUrl used to rewrite the value on the way in. + + A bare-origin URL is the case that shows it. AnyUrl parses + "https://example.com" and renders it back as "https://example.com/", + so a consumer comparing the permalink against the platform's own copy + got a mismatch. Plain str hands back what the API sent. + """ from late.models import PlatformTarget - url = "https://www.tiktok.com/@crezio.ai/video/7675444697107614990" + url = "https://example.com" target = PlatformTarget.model_validate( {"platform": "tiktok", "status": "published", "platformPostUrl": url} ) - assert str(target.platformPostUrl) == url + assert target.platformPostUrl == url @pytest.mark.usefixtures("fake_api")