From 9e535cbc6c81001eb013786ab276aaa67235e524 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 13 Jul 2026 12:47:53 +0100 Subject: [PATCH 1/7] feat(tasks): post agent thread updates for canvas creation and turn completion Canvas generation runs in the cloud, so thread announcements must come from the server, not a client that may be closed. On a canvas's first publish (attributed via the X-PostHog-Task-Id header the sandbox stamps on MCP calls), the generating task's thread gets an authorless "[name](link) has been created" message. On every end-of-turn the sandbox relay observes for a background channel-task run, the thread gets a turn-complete note @-mentioning the task creator. Both are gated on the project-bluebird flag and are best-effort. Generated-By: PostHog Code Task-Id: ea09a661-1f6d-4bd8-a046-24be60909d28 --- posthog/api/file_system/file_system.py | 57 ++++++++- .../file_system/test/test_canvas_publish.py | 55 +++++++++ products/tasks/backend/facade/api.py | 85 ++++++++++++- products/tasks/backend/mentions.py | 10 ++ .../activities/relay_sandbox_events.py | 14 +++ .../backend/tests/test_thread_updates.py | 115 ++++++++++++++++++ 6 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 products/tasks/backend/tests/test_thread_updates.py diff --git a/posthog/api/file_system/file_system.py b/posthog/api/file_system/file_system.py index 84c81ddd4b61..5d7e2b2946b6 100644 --- a/posthog/api/file_system/file_system.py +++ b/posthog/api/file_system/file_system.py @@ -3,8 +3,9 @@ import shlex import builtins from typing import Any, cast -from uuid import uuid4 +from uuid import UUID, uuid4 +from django.conf import settings from django.db import transaction from django.db.models import Case, F, IntegerField, Q, QuerySet, Value, When from django.db.models.functions import Concat, Lower @@ -63,6 +64,8 @@ from posthog.models.user import User from posthog.utils import str_to_bool +from products.tasks.backend.facade import api as tasks_facade + DELETE_PREVIEW_ENTRY_LIMIT = 200 # Search-within-Recents scans this many of the user's most-recent views, then the text filter trims @@ -1132,6 +1135,7 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons if isinstance(existing_context, str): version["context"] = existing_context versions = list(meta.get("versions") or []) + first_publish = not versions and not meta.get("code") versions.append(version) meta.update( @@ -1156,8 +1160,59 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons dashboard.save(update_fields=update_fields) + if first_publish: + self._announce_canvas_created(request, dashboard) + return Response(self.get_serializer(dashboard).data) + def _announce_canvas_created(self, request: Request, dashboard: FileSystem) -> None: + """Announce a canvas's first publish in the generating task's thread. + + The task sandbox stamps every MCP call with an X-PostHog-Task-Id header, so + a publish is attributable to the task that made it. Attribution only, not an + authorization boundary: the bearer token already had to be able to write the + canvas, and the header must name a task in the same team. No header (a human + or app save) means no announcement. + """ + raw_task_id = (request.headers.get("X-PostHog-Task-Id") or "").strip() + try: + task_id = UUID(raw_task_id) + except ValueError: + return + if not tasks_facade.task_exists(task_id, self.team_id): + return + segments = split_path(dashboard.path) + tasks_facade.post_canvas_created_thread_update( + task_id, + self.team_id, + canvas_name=segments[-1] if segments else "Canvas", + canvas_url=self._canvas_share_url(dashboard), + ) + + def _canvas_share_url(self, dashboard: FileSystem) -> str | None: + """The web interstitial link that deep-links into the desktop app's canvas view: + `/code/canvas//`. The channel id is stamped on + the row's meta by the desktop app at create time; fall back to the parent folder + row for rows that predate the stamp. + """ + channel_id = (dashboard.meta or {}).get("channelId") + if not channel_id: + parent_path = join_path(split_path(dashboard.path)[:-1]) + folder = ( + FileSystem.objects.filter( + surface_q(self.file_system_surface), + team_id=dashboard.team_id, + type="folder", + path=parent_path, + ).first() + if parent_path + else None + ) + channel_id = str(folder.id) if folder else None + if not channel_id: + return None + return f"{settings.SITE_URL}/code/canvas/{channel_id}/{dashboard.id}" + @extend_schema(responses={200: FolderInstructionsSerializer}) @action(methods=["GET"], detail=True) def instructions(self, request: Request, *args: Any, **kwargs: Any) -> Response: diff --git a/posthog/api/file_system/test/test_canvas_publish.py b/posthog/api/file_system/test/test_canvas_publish.py index 791c929bdce0..6b44d98057e2 100644 --- a/posthog/api/file_system/test/test_canvas_publish.py +++ b/posthog/api/file_system/test/test_canvas_publish.py @@ -1,11 +1,16 @@ from typing import cast from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from django.conf import settings from rest_framework import status from posthog.models.file_system.file_system import FileSystem +from products.tasks.backend.models import Task, TaskThreadMessage + class TestDesktopCanvasPublishAPI(APIBaseTest): def setUp(self): @@ -96,6 +101,56 @@ def test_publish_canvas_requires_code(self): self.assertEqual(bad.status_code, status.HTTP_400_BAD_REQUEST, bad.json()) self.assertIn("code", bad.json()) + def _create_task(self) -> Task: + return Task.objects.create( + team=self.team, + title="Generate canvas", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + created_by=self.user, + ) + + def _thread_messages(self, task: Task): + return TaskThreadMessage.objects.for_team(self.team.id).filter(task=task) + + @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) + def test_first_publish_from_task_announces_in_thread_once(self, _flag): + task = self._create_task() + item_id = self._create_dashboard(meta={"channelId": "chan-1"}) + + self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + + messages = self._thread_messages(task) + self.assertEqual(messages.count(), 1) + message = messages.get() + self.assertIsNone(message.author_id) + self.assertEqual( + message.content, + f"[MyCanvas]({settings.SITE_URL}/code/canvas/chan-1/{item_id}) has been created", + ) + + # A second publish updates the canvas, it doesn't create it again. + self.client.patch(self._canvas_url(item_id), {"code": "v2"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + self.assertEqual(messages.count(), 1) + + @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) + def test_announcement_links_via_parent_folder_when_meta_has_no_channel(self, _flag): + task = self._create_task() + item_id = self._create_dashboard() # no channelId stamp — rows created before the app stamped it + + self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + + folder = FileSystem.objects.get(team=self.team, path="MyChannel", type="folder") + message = self._thread_messages(task).get() + self.assertTrue(message.content.startswith(f"[MyCanvas]({settings.SITE_URL}/code/canvas/{folder.id}/")) + + def test_publish_without_task_attribution_stays_silent(self): + item_id = self._create_dashboard() + + self.client.patch(self._canvas_url(item_id), {"code": "v1"}) + + self.assertFalse(TaskThreadMessage.objects.for_team(self.team.id).exists()) + def test_delete_canvas_removes_ref_less_dashboard_row(self): # Desktop canvases are `dashboard`-typed rows with no ref; deleting one must not # trip the "without a reference" guard meant for real object-backed rows. diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 5a9327a6f418..d57f7fdfcfb5 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -24,7 +24,7 @@ from uuid import UUID, uuid4 from django.conf import settings -from django.db import IntegrityError, transaction +from django.db import IntegrityError, close_old_connections, transaction from django.db.models import CharField, Count, Exists, F, Min, OuterRef, Q, QuerySet, Subquery from django.db.models.fields.json import KeyTextTransform from django.utils import timezone as django_timezone @@ -49,7 +49,7 @@ is_custom_images_enabled, read_spec_from_builder_sandbox, ) -from products.tasks.backend.mentions import resolve_mentioned_user_ids +from products.tasks.backend.mentions import format_mention_token, resolve_mentioned_user_ids from products.tasks.backend.models import ( Channel, ChannelFeedMessage, @@ -5018,6 +5018,87 @@ def forward_thread_message( return "ok", _thread_message_to_dto(message) +# Threads are a Channels (project-bluebird) surface, so agent-authored thread +# updates are gated on the same flag — evaluated for the task creator. +AGENT_THREAD_UPDATES_FLAG = "project-bluebird" + +# One turn-complete post per run within the window, so an SSE relay reconnect +# replaying the tail of the stream can't double-post the same end-of-turn. +_TURN_COMPLETE_COOLDOWN_SECONDS = 30 + + +def _create_agent_thread_message(task: Task, content: str) -> None: + """Write an authorless (agent) thread message and index its mentions.""" + message = TaskThreadMessage.objects.create(team_id=task.team_id, task_id=task.id, author_id=None, content=content) + try: + _index_thread_message_mentions(message) + except Exception: + logger.exception("Failed to index thread message mentions", extra={"message_id": str(message.id)}) + + +def _agent_thread_updates_enabled(creator: User | None) -> bool: + """Fail closed: no creator to key the flag on, or a flag-service error, means no post.""" + if creator is None: + return False + distinct_id = creator.distinct_id or f"user_{creator.id}" + try: + return bool( + posthoganalytics.feature_enabled(AGENT_THREAD_UPDATES_FLAG, distinct_id, send_feature_flag_events=False) + ) + except Exception: + logger.warning("Agent thread update flag check failed", extra={"user_id": creator.id}, exc_info=True) + return False + + +def post_canvas_created_thread_update( + task_id: str | UUID, team_id: int, *, canvas_name: str, canvas_url: str | None +) -> None: + """Announce a freshly created canvas in the generating task's thread. + + Posts "[name](url) has been created" as an authorless (agent) message. Called + on a canvas's first publish only — the caller owns that once-guard. Best-effort + and never raises: the publish must not fail because its announcement couldn't + be written. + """ + try: + task = Task.objects.select_related("created_by").filter(id=task_id, team_id=team_id).first() + if task is None or not _agent_thread_updates_enabled(task.created_by): + return + # Brackets and newlines in the name would break the [label](url) token. + name = re.sub(r"[\[\]\n]", " ", canvas_name).strip() or "Canvas" + content = f"[{name}]({canvas_url}) has been created" if canvas_url else f"{name} has been created" + _create_agent_thread_message(task, content) + except Exception: + logger.exception("Failed to post canvas-created thread update", extra={"task_id": str(task_id)}) + + +def post_turn_complete_thread_update(run_id: str | UUID, task_id: str | UUID, team_id: int) -> None: + """Announce a finished agent turn in the task's thread, @-mentioning the task creator. + + Fires from the sandbox event relay on every end-of-turn of a channel task's + background run, so the update lands even with no client open. Best-effort and + never raises — a failed post must not disturb the relay. + """ + try: + if not settings.TEST: + close_old_connections() + task = Task.objects.select_related("created_by").filter(id=task_id, team_id=team_id).first() + # Threads hang off a task's channel feed; a channel-less task has no audience. + if task is None or task.channel_id is None: + return + creator = task.created_by + if creator is None or not _agent_thread_updates_enabled(creator): + return + from products.tasks.backend.redis import get_tasks_cache # noqa: PLC0415 — keep redis off the api import path + + if not get_tasks_cache().add(f"thread_update:{run_id}:turn_complete", True, _TURN_COMPLETE_COOLDOWN_SECONDS): + return + mention = format_mention_token(creator.get_full_name() or creator.email, creator.email) + _create_agent_thread_message(task, f"{mention} Turn complete.") + except Exception: + logger.exception("Failed to post turn-complete thread update", extra={"task_id": str(task_id)}) + + def respond_to_permission_request( run_id: str | UUID, task_id: str | UUID, diff --git a/products/tasks/backend/mentions.py b/products/tasks/backend/mentions.py index a65daf954d2a..f0ee4cad5204 100644 --- a/products/tasks/backend/mentions.py +++ b/products/tasks/backend/mentions.py @@ -22,6 +22,16 @@ def extract_mention_emails(content: str) -> set[str]: return {match.group(1).lower() for match in MENTION_TOKEN_PATTERN.finditer(content)} +def format_mention_token(name: str, email: str) -> str: + """Serialize a user reference into the inline mention token. + + Brackets and newlines would break token parsing; the email is the identity, + so the name falls back to its local part when unusable. + """ + safe_name = re.sub(r"[\[\]\n]", " ", name).strip() or email.split("@")[0] or email + return f"@[{safe_name}]({email})" + + def resolve_mentioned_user_ids(user_model: Any, content: str, *, team_id: int, author_id: int | None) -> list[int]: """Ids of the team's org members mentioned in the content, excluding the author. diff --git a/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py b/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py index 0808bfb9c66f..fdf6cbf6ef3f 100644 --- a/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py +++ b/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py @@ -19,6 +19,7 @@ from posthog.temporal.common.utils import close_db_connections +from products.tasks.backend.facade import api as tasks_facade from products.tasks.backend.logic.services.agent_command import validate_sandbox_url from products.tasks.backend.logic.services.connection_token import create_sandbox_connection_token from products.tasks.backend.logic.services.permission_broker import ( @@ -374,6 +375,19 @@ async def _relay_loop( # does sync Redis (cache.add) and a potential network call to # the feature-flag service. asyncio.create_task(asyncio.to_thread(_safe_dispatch_awaiting_input, task_run)) + if task_run is not None and task_run.mode != "interactive": + # Background run finished a turn — announce it in the task's + # thread so teammates following it see progress without a + # client open. Guards (flag, channel, cooldown) live in the + # facade; same thread hop as above for its sync I/O. + asyncio.create_task( + asyncio.to_thread( + tasks_facade.post_turn_complete_thread_update, + str(task_run.id), + str(task_run.task_id), + task_run.team_id, + ) + ) if is_agent_design_enabled and slack_turn_active[0] and workflow_handle is not None: slack_turn_active[0] = False asyncio.create_task(_signal_safely(workflow_handle, "turn_completed")) diff --git a/products/tasks/backend/tests/test_thread_updates.py b/products/tasks/backend/tests/test_thread_updates.py new file mode 100644 index 000000000000..38cc44f9d9f5 --- /dev/null +++ b/products/tasks/backend/tests/test_thread_updates.py @@ -0,0 +1,115 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase + +from parameterized import parameterized + +from posthog.models import Organization, OrganizationMembership, Team, User + +from products.tasks.backend.facade.api import post_canvas_created_thread_update, post_turn_complete_thread_update +from products.tasks.backend.models import Channel, Task, TaskRun, TaskThreadMessage, TaskThreadMessageMention + +_FLAG_TARGET = "products.tasks.backend.facade.api.posthoganalytics.feature_enabled" + + +class TestAgentThreadUpdates(TestCase): + def setUp(self) -> None: + cache.clear() + self.organization = Organization.objects.create(name="Test Org") + self.team = Team.objects.create(organization=self.organization, name="Test Team") + self.user = User.objects.create_user( + email="creator@example.com", first_name="Casey", last_name="Creator", password="password" + ) + OrganizationMembership.objects.create(user=self.user, organization=self.organization) + self.channel = Channel.objects.create(team=self.team, name="general") + self.task = Task.objects.create( + team=self.team, + title="Build canvas", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + created_by=self.user, + channel=self.channel, + ) + self.run = TaskRun.objects.create(task=self.task, team=self.team) + + def _messages(self, task: Task) -> list[TaskThreadMessage]: + return list(TaskThreadMessage.objects.for_team(self.team.id).filter(task=task).order_by("created_at")) + + @patch(_FLAG_TARGET, return_value=True) + def test_turn_complete_posts_authorless_message_mentioning_creator(self, _flag) -> None: + post_turn_complete_thread_update(str(self.run.id), str(self.task.id), self.team.id) + + messages = self._messages(self.task) + self.assertEqual(len(messages), 1) + self.assertIsNone(messages[0].author_id) + self.assertEqual(messages[0].content, "@[Casey Creator](creator@example.com) Turn complete.") + # The creator's mention is indexed so it lands in their mentions feed. + self.assertTrue( + TaskThreadMessageMention.objects.for_team(self.team.id) + .filter(message=messages[0], mentioned_user=self.user) + .exists() + ) + + @patch(_FLAG_TARGET, return_value=True) + def test_turn_complete_cooldown_collapses_duplicate_end_of_turn_events(self, _flag) -> None: + post_turn_complete_thread_update(str(self.run.id), str(self.task.id), self.team.id) + post_turn_complete_thread_update(str(self.run.id), str(self.task.id), self.team.id) + + self.assertEqual(len(self._messages(self.task)), 1) + + @parameterized.expand( + [ + ("flag_off", False, True, True), + ("no_channel", True, False, True), + ("no_creator", True, True, False), + ] + ) + @patch(_FLAG_TARGET) + def test_turn_complete_skips(self, _name, flag_on, has_channel, has_creator, flag_mock) -> None: + flag_mock.return_value = flag_on + task = Task.objects.create( + team=self.team, + title="Other task", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + created_by=self.user if has_creator else None, + channel=self.channel if has_channel else None, + ) + run = TaskRun.objects.create(task=task, team=self.team) + + post_turn_complete_thread_update(str(run.id), str(task.id), self.team.id) + + self.assertEqual(self._messages(task), []) + + @parameterized.expand( + [ + ( + "with_link", + "Signups overview", + "https://us.posthog.com/code/canvas/c/d", + "[Signups overview](https://us.posthog.com/code/canvas/c/d) has been created", + ), + ( + "name_sanitized_for_link_token", + "[Q3] KPIs", + "https://us.posthog.com/code/canvas/c/d", + "[Q3 KPIs](https://us.posthog.com/code/canvas/c/d) has been created", + ), + ("without_link", "Signups overview", None, "Signups overview has been created"), + ] + ) + @patch(_FLAG_TARGET, return_value=True) + def test_canvas_created_message_content(self, _name, canvas_name, canvas_url, expected, _flag) -> None: + post_canvas_created_thread_update(self.task.id, self.team.id, canvas_name=canvas_name, canvas_url=canvas_url) + + messages = self._messages(self.task) + self.assertEqual(len(messages), 1) + self.assertIsNone(messages[0].author_id) + self.assertEqual(messages[0].content, expected) + + @patch(_FLAG_TARGET, return_value=False) + def test_canvas_created_skips_when_flag_off(self, _flag) -> None: + post_canvas_created_thread_update(self.task.id, self.team.id, canvas_name="Canvas", canvas_url=None) + + self.assertEqual(self._messages(self.task), []) From c0a900439f6f413ebcd15fa1e002e77f6c397d4f Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 13 Jul 2026 12:47:55 +0100 Subject: [PATCH 2/7] fix(tasks): resolve tach boundary and ty shadowing in thread update tests The endpoint test lives outside the isolated tasks product, so it now loads Task/TaskThreadMessage via apps.get_model (the pattern test_folder_context_generation uses) instead of importing product internals. Renamed self.run to self.task_run since it shadowed TestCase.run. Generated-By: PostHog Code Task-Id: ea09a661-1f6d-4bd8-a046-24be60909d28 --- .../api/file_system/test/test_canvas_publish.py | 15 +++++++++++---- .../tasks/backend/tests/test_thread_updates.py | 8 ++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/posthog/api/file_system/test/test_canvas_publish.py b/posthog/api/file_system/test/test_canvas_publish.py index 6b44d98057e2..f8578c7ce2cf 100644 --- a/posthog/api/file_system/test/test_canvas_publish.py +++ b/posthog/api/file_system/test/test_canvas_publish.py @@ -1,15 +1,17 @@ -from typing import cast +from typing import TYPE_CHECKING, cast from posthog.test.base import APIBaseTest from unittest.mock import patch +from django.apps import apps from django.conf import settings from rest_framework import status from posthog.models.file_system.file_system import FileSystem -from products.tasks.backend.models import Task, TaskThreadMessage +if TYPE_CHECKING: + from products.tasks.backend.models import Task class TestDesktopCanvasPublishAPI(APIBaseTest): @@ -101,7 +103,10 @@ def test_publish_canvas_requires_code(self): self.assertEqual(bad.status_code, status.HTTP_400_BAD_REQUEST, bad.json()) self.assertIn("code", bad.json()) - def _create_task(self) -> Task: + # Task models load via the app registry: this test lives outside the isolated + # tasks product, so it can't import its internals (tach-enforced). + def _create_task(self) -> "Task": + Task = apps.get_model("tasks", "Task") return Task.objects.create( team=self.team, title="Generate canvas", @@ -110,7 +115,8 @@ def _create_task(self) -> Task: created_by=self.user, ) - def _thread_messages(self, task: Task): + def _thread_messages(self, task: "Task"): + TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage") return TaskThreadMessage.objects.for_team(self.team.id).filter(task=task) @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) @@ -149,6 +155,7 @@ def test_publish_without_task_attribution_stays_silent(self): self.client.patch(self._canvas_url(item_id), {"code": "v1"}) + TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage") self.assertFalse(TaskThreadMessage.objects.for_team(self.team.id).exists()) def test_delete_canvas_removes_ref_less_dashboard_row(self): diff --git a/products/tasks/backend/tests/test_thread_updates.py b/products/tasks/backend/tests/test_thread_updates.py index 38cc44f9d9f5..ef72579d03c3 100644 --- a/products/tasks/backend/tests/test_thread_updates.py +++ b/products/tasks/backend/tests/test_thread_updates.py @@ -31,14 +31,14 @@ def setUp(self) -> None: created_by=self.user, channel=self.channel, ) - self.run = TaskRun.objects.create(task=self.task, team=self.team) + self.task_run = TaskRun.objects.create(task=self.task, team=self.team) def _messages(self, task: Task) -> list[TaskThreadMessage]: return list(TaskThreadMessage.objects.for_team(self.team.id).filter(task=task).order_by("created_at")) @patch(_FLAG_TARGET, return_value=True) def test_turn_complete_posts_authorless_message_mentioning_creator(self, _flag) -> None: - post_turn_complete_thread_update(str(self.run.id), str(self.task.id), self.team.id) + post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id) messages = self._messages(self.task) self.assertEqual(len(messages), 1) @@ -53,8 +53,8 @@ def test_turn_complete_posts_authorless_message_mentioning_creator(self, _flag) @patch(_FLAG_TARGET, return_value=True) def test_turn_complete_cooldown_collapses_duplicate_end_of_turn_events(self, _flag) -> None: - post_turn_complete_thread_update(str(self.run.id), str(self.task.id), self.team.id) - post_turn_complete_thread_update(str(self.run.id), str(self.task.id), self.team.id) + post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id) + post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id) self.assertEqual(len(self._messages(self.task)), 1) From dab7dc9d51ce9509849515754241a101d6873ea3 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 13 Jul 2026 12:47:57 +0100 Subject: [PATCH 3/7] feat(tasks): relay the agent's final turn message in thread updates The turn-complete thread post now carries the agent's closing message instead of a static "Turn complete." The relay accumulates agent_message_chunk text and resets on a new tool call or user message, so end-of-turn holds the prose after the last tool call. Falls back to "Turn complete." when no text was captured (e.g. after a reconnect), and the facade caps oversized messages. Generated-By: PostHog Code Task-Id: ea09a661-1f6d-4bd8-a046-24be60909d28 --- products/tasks/backend/facade/api.py | 20 +++++-- .../activities/relay_sandbox_events.py | 35 +++++++++-- .../backend/tests/test_thread_updates.py | 58 +++++++++++++++++-- 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index d57f7fdfcfb5..7f1bc8f73f25 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -5026,6 +5026,9 @@ def forward_thread_message( # replaying the tail of the stream can't double-post the same end-of-turn. _TURN_COMPLETE_COOLDOWN_SECONDS = 30 +# Cap the relayed final message so one agent essay can't dwarf the thread. +_TURN_MESSAGE_MAX_CHARS = 4000 + def _create_agent_thread_message(task: Task, content: str) -> None: """Write an authorless (agent) thread message and index its mentions.""" @@ -5072,12 +5075,16 @@ def post_canvas_created_thread_update( logger.exception("Failed to post canvas-created thread update", extra={"task_id": str(task_id)}) -def post_turn_complete_thread_update(run_id: str | UUID, task_id: str | UUID, team_id: int) -> None: - """Announce a finished agent turn in the task's thread, @-mentioning the task creator. +def post_turn_complete_thread_update( + run_id: str | UUID, task_id: str | UUID, team_id: int, *, message: str | None = None +) -> None: + """Post the agent's final turn message into the task's thread, @-mentioning the task creator. Fires from the sandbox event relay on every end-of-turn of a channel task's - background run, so the update lands even with no client open. Best-effort and - never raises — a failed post must not disturb the relay. + background run, so the update lands even with no client open. ``message`` is + the agent's closing prose for the turn; when the relay captured none, a plain + "Turn complete." stands in. Best-effort and never raises — a failed post must + not disturb the relay. """ try: if not settings.TEST: @@ -5093,8 +5100,11 @@ def post_turn_complete_thread_update(run_id: str | UUID, task_id: str | UUID, te if not get_tasks_cache().add(f"thread_update:{run_id}:turn_complete", True, _TURN_COMPLETE_COOLDOWN_SECONDS): return + body = (message or "").strip() or "Turn complete." + if len(body) > _TURN_MESSAGE_MAX_CHARS: + body = body[: _TURN_MESSAGE_MAX_CHARS - 1] + "…" mention = format_mention_token(creator.get_full_name() or creator.email, creator.email) - _create_agent_thread_message(task, f"{mention} Turn complete.") + _create_agent_thread_message(task, f"{mention} {body}") except Exception: logger.exception("Failed to post turn-complete thread update", extra={"task_id": str(task_id)}) diff --git a/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py b/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py index fdf6cbf6ef3f..52259ca2ad05 100644 --- a/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py +++ b/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py @@ -304,6 +304,10 @@ async def _relay_loop( last_audit_ts_ns: list[int] = [0] # track last agentsh audit timestamp # Brackets turn_started / turn_completed signals to the parent. slack_turn_active: list[bool] = [False] + # The agent's in-progress closing message for the current turn. Chunks + # accumulate; a new tool call or user message resets it, so at end-of-turn + # it holds the prose after the last tool call — what the thread update posts. + final_message_parts: list[str] = [] # ACP emits one tool_call + N tool_call_update per id; only render the start. emitted_tool_call_ids: set[str] = set() @@ -376,24 +380,30 @@ async def _relay_loop( # the feature-flag service. asyncio.create_task(asyncio.to_thread(_safe_dispatch_awaiting_input, task_run)) if task_run is not None and task_run.mode != "interactive": - # Background run finished a turn — announce it in the task's - # thread so teammates following it see progress without a - # client open. Guards (flag, channel, cooldown) live in the - # facade; same thread hop as above for its sync I/O. + # Background run finished a turn — post its closing message + # into the task's thread so teammates following it see the + # outcome without a client open. Guards (flag, channel, + # cooldown) live in the facade; same thread hop as above + # for its sync I/O. asyncio.create_task( asyncio.to_thread( tasks_facade.post_turn_complete_thread_update, str(task_run.id), str(task_run.task_id), task_run.team_id, + message="".join(final_message_parts).strip() or None, ) ) + final_message_parts.clear() if is_agent_design_enabled and slack_turn_active[0] and workflow_handle is not None: slack_turn_active[0] = False asyncio.create_task(_signal_safely(workflow_handle, "turn_completed")) elif not agent_active[0] and _is_active_agent_update(event_data): agent_active[0] = True + if task_run is not None and task_run.mode != "interactive": + _track_final_message(event_data, final_message_parts) + # Agent-design signal fan-out: first session/update opens the # child relay; tool_call → step, agent_message_chunk → markdown. if is_agent_design_enabled and workflow_handle is not None: @@ -446,6 +456,7 @@ async def _relay_loop( reconnect_count += 1 # May have missed an end_of_turn on the dropped stream — assume idle until re-confirmed. agent_active[0] = False + final_message_parts.clear() logger.warning( "relay_sandbox_events_read_timeout", run_id=run_id, @@ -467,6 +478,7 @@ async def _relay_loop( # 5xx — transient server error, worth retrying reconnect_count += 1 agent_active[0] = False # missed-end_of_turn guard (see ReadTimeout above) + final_message_parts.clear() logger.warning( "relay_sandbox_events_http_error", run_id=run_id, @@ -479,6 +491,7 @@ async def _relay_loop( except (httpx.TransportError, httpx_sse.SSEError) as e: reconnect_count += 1 agent_active[0] = False # missed-end_of_turn guard (see ReadTimeout above) + final_message_parts.clear() logger.warning( "relay_sandbox_events_connection_error", run_id=run_id, @@ -608,6 +621,20 @@ def _tool_args_preview(raw_input: Any) -> str | None: return one_line +def _track_final_message(event_data: dict, parts: list[str]) -> None: + """Accumulate agent_message_chunk text; a new tool call or user message resets, + so `parts` ends the turn holding only the agent's closing prose.""" + text = _extract_agent_message_text(event_data) + if text: + parts.append(text) + return + if not _is_session_update(event_data): + return + update = (event_data.get("notification", {}).get("params") or {}).get("update") or {} + if update.get("sessionUpdate") in ("tool_call", "user_message", "user_message_chunk"): + parts.clear() + + def _extract_agent_message_text(event_data: dict) -> str | None: """Text delta from an ACP agent_message_chunk session/update, else None.""" notification = event_data.get("notification", {}) diff --git a/products/tasks/backend/tests/test_thread_updates.py b/products/tasks/backend/tests/test_thread_updates.py index ef72579d03c3..3446867adbe4 100644 --- a/products/tasks/backend/tests/test_thread_updates.py +++ b/products/tasks/backend/tests/test_thread_updates.py @@ -1,7 +1,7 @@ from unittest.mock import patch from django.core.cache import cache -from django.test import TestCase +from django.test import SimpleTestCase, TestCase from parameterized import parameterized @@ -9,6 +9,7 @@ from products.tasks.backend.facade.api import post_canvas_created_thread_update, post_turn_complete_thread_update from products.tasks.backend.models import Channel, Task, TaskRun, TaskThreadMessage, TaskThreadMessageMention +from products.tasks.backend.temporal.process_task.activities.relay_sandbox_events import _track_final_message _FLAG_TARGET = "products.tasks.backend.facade.api.posthoganalytics.feature_enabled" @@ -36,14 +37,24 @@ def setUp(self) -> None: def _messages(self, task: Task) -> list[TaskThreadMessage]: return list(TaskThreadMessage.objects.for_team(self.team.id).filter(task=task).order_by("created_at")) + @parameterized.expand( + [ + ( + "relays_final_message", + "Shipped the canvas with three charts.", + "@[Casey Creator](creator@example.com) Shipped the canvas with three charts.", + ), + ("falls_back_without_message", None, "@[Casey Creator](creator@example.com) Turn complete."), + ] + ) @patch(_FLAG_TARGET, return_value=True) - def test_turn_complete_posts_authorless_message_mentioning_creator(self, _flag) -> None: - post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id) + def test_turn_complete_posts_authorless_message_mentioning_creator(self, _name, message, expected, _flag) -> None: + post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id, message=message) messages = self._messages(self.task) self.assertEqual(len(messages), 1) self.assertIsNone(messages[0].author_id) - self.assertEqual(messages[0].content, "@[Casey Creator](creator@example.com) Turn complete.") + self.assertEqual(messages[0].content, expected) # The creator's mention is indexed so it lands in their mentions feed. self.assertTrue( TaskThreadMessageMention.objects.for_team(self.team.id) @@ -51,6 +62,14 @@ def test_turn_complete_posts_authorless_message_mentioning_creator(self, _flag) .exists() ) + @patch(_FLAG_TARGET, return_value=True) + def test_turn_complete_truncates_oversized_message(self, _flag) -> None: + post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id, message="x" * 5000) + + content = self._messages(self.task)[0].content + self.assertTrue(content.endswith("…")) + self.assertLess(len(content), 4100) + @patch(_FLAG_TARGET, return_value=True) def test_turn_complete_cooldown_collapses_duplicate_end_of_turn_events(self, _flag) -> None: post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id) @@ -113,3 +132,34 @@ def test_canvas_created_skips_when_flag_off(self, _flag) -> None: post_canvas_created_thread_update(self.task.id, self.team.id, canvas_name="Canvas", canvas_url=None) self.assertEqual(self._messages(self.task), []) + + +def _session_update(update: dict) -> dict: + return {"type": "notification", "notification": {"method": "session/update", "params": {"update": update}}} + + +def _chunk(text: str) -> dict: + return _session_update({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}) + + +class TestTrackFinalMessage(SimpleTestCase): + def test_holds_only_prose_after_last_tool_call(self) -> None: + parts: list[str] = [] + for event in [ + _chunk("Let me look at the data first. "), + _session_update({"sessionUpdate": "tool_call", "toolCallId": "t1"}), + _session_update({"sessionUpdate": "tool_call_update", "toolCallId": "t1"}), + _chunk("Done. The canvas "), + _chunk("shows signups by week."), + ]: + _track_final_message(event, parts) + + self.assertEqual("".join(parts), "Done. The canvas shows signups by week.") + + @parameterized.expand([("user_message",), ("user_message_chunk",), ("tool_call",)]) + def test_resets_on(self, session_update: str) -> None: + parts = ["stale narration"] + + _track_final_message(_session_update({"sessionUpdate": session_update}), parts) + + self.assertEqual(parts, []) From e5c17ff3fea564233b9cae67f994e952ccdbeb63 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 13 Jul 2026 12:50:40 +0100 Subject: [PATCH 4/7] feat(tasks): align agent thread rows with channel feed message conventions Stacked on the ChannelFeedMessage PR: TaskThreadMessage gains author_kind/event/payload mirroring its shape. Agent rows are now author_kind=agent with a stable event key (canvas_created, turn_complete) and structured payload; turn_complete carries run_id so clients rendering live session-derived agent turns can dedupe against the durable row. Content keeps the rendered text so older clients degrade cleanly. Additive migration 0057 on posthog_task_thread_message (no external writers). Generated-By: PostHog Code Task-Id: ea09a661-1f6d-4bd8-a046-24be60909d28 --- products/tasks/backend/facade/api.py | 38 ++++++++++++++++--- products/tasks/backend/facade/contracts.py | 3 ++ .../0057_taskthreadmessage_agent_fields.py | 29 ++++++++++++++ .../backend/migrations/max_migration.txt | 2 +- products/tasks/backend/models.py | 20 ++++++++-- .../tasks/backend/presentation/serializers.py | 13 ++++++- .../backend/tests/test_thread_updates.py | 7 ++++ 7 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 products/tasks/backend/migrations/0057_taskthreadmessage_agent_fields.py diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 7f1bc8f73f25..843c2eef6ae3 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -4863,6 +4863,9 @@ def _thread_message_to_dto(message: TaskThreadMessage) -> contracts.TaskThreadMe return contracts.TaskThreadMessageDTO( id=message.id, task=message.task_id, + author_kind=message.author_kind, + event=message.event, + payload=message.payload or {}, content=message.content, created_at=message.created_at, author=_user_basic_info(message.author if message.author_id else None), @@ -5030,9 +5033,22 @@ def forward_thread_message( _TURN_MESSAGE_MAX_CHARS = 4000 -def _create_agent_thread_message(task: Task, content: str) -> None: - """Write an authorless (agent) thread message and index its mentions.""" - message = TaskThreadMessage.objects.create(team_id=task.team_id, task_id=task.id, author_id=None, content=content) +def _create_agent_thread_message(task: Task, content: str, *, event: str, payload: dict | None = None) -> None: + """Write an agent-authored thread message and index its mentions. + + ``content`` is the rendered text (older clients show it as-is); ``event`` + + ``payload`` are the structured record, mirroring ChannelFeedMessage, that + lets clients render agent rows natively and dedupe them against live views. + """ + message = TaskThreadMessage.objects.create( + team_id=task.team_id, + task_id=task.id, + author_id=None, + author_kind=TaskThreadMessage.AuthorKind.AGENT, + event=event, + payload=payload or {}, + content=content, + ) try: _index_thread_message_mentions(message) except Exception: @@ -5070,7 +5086,12 @@ def post_canvas_created_thread_update( # Brackets and newlines in the name would break the [label](url) token. name = re.sub(r"[\[\]\n]", " ", canvas_name).strip() or "Canvas" content = f"[{name}]({canvas_url}) has been created" if canvas_url else f"{name} has been created" - _create_agent_thread_message(task, content) + _create_agent_thread_message( + task, + content, + event="canvas_created", + payload={"canvas_name": name, "canvas_url": canvas_url}, + ) except Exception: logger.exception("Failed to post canvas-created thread update", extra={"task_id": str(task_id)}) @@ -5104,7 +5125,14 @@ def post_turn_complete_thread_update( if len(body) > _TURN_MESSAGE_MAX_CHARS: body = body[: _TURN_MESSAGE_MAX_CHARS - 1] + "…" mention = format_mention_token(creator.get_full_name() or creator.email, creator.email) - _create_agent_thread_message(task, f"{mention} {body}") + # payload.run_id is the dedupe key: a client already rendering this run's + # live agent turns can suppress the durable row (or vice versa). + _create_agent_thread_message( + task, + f"{mention} {body}", + event="turn_complete", + payload={"run_id": str(run_id)}, + ) except Exception: logger.exception("Failed to post turn-complete thread update", extra={"task_id": str(task_id)}) diff --git a/products/tasks/backend/facade/contracts.py b/products/tasks/backend/facade/contracts.py index 1cb022e31fe3..29b2007bf035 100644 --- a/products/tasks/backend/facade/contracts.py +++ b/products/tasks/backend/facade/contracts.py @@ -153,6 +153,9 @@ class TaskThreadMessageDTO: id: UUID task: UUID + author_kind: str + event: str + payload: dict content: str created_at: datetime author: "TaskUserBasicInfo | None" = None diff --git a/products/tasks/backend/migrations/0057_taskthreadmessage_agent_fields.py b/products/tasks/backend/migrations/0057_taskthreadmessage_agent_fields.py new file mode 100644 index 000000000000..6ccad078fb3d --- /dev/null +++ b/products/tasks/backend/migrations/0057_taskthreadmessage_agent_fields.py @@ -0,0 +1,29 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0056_channelfeedmessage"), + ] + + operations = [ + migrations.AddField( + model_name="taskthreadmessage", + name="author_kind", + field=models.CharField( + choices=[("human", "Human"), ("system", "System"), ("agent", "Agent")], + default="human", + max_length=16, + ), + ), + migrations.AddField( + model_name="taskthreadmessage", + name="event", + field=models.CharField(blank=True, default="", max_length=64), + ), + migrations.AddField( + model_name="taskthreadmessage", + name="payload", + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/products/tasks/backend/migrations/max_migration.txt b/products/tasks/backend/migrations/max_migration.txt index f14ffd9a113d..5c64038bac7f 100644 --- a/products/tasks/backend/migrations/max_migration.txt +++ b/products/tasks/backend/migrations/max_migration.txt @@ -1 +1 @@ -0056_channelfeedmessage +0057_taskthreadmessage_agent_fields diff --git a/products/tasks/backend/models.py b/products/tasks/backend/models.py index 648835496914..12abdc851f5a 100644 --- a/products/tasks/backend/models.py +++ b/products/tasks/backend/models.py @@ -812,9 +812,18 @@ def _dispatch() -> None: class TaskThreadMessage(TeamScopedRootMixin): - """One human message in a task's thread — the side conversation channel members - have around a task. Messages never reach the agent unless the task author - forwards one (send_to_agent), which stamps the forwarded_* fields.""" + """One message in a task's thread — the side conversation channel members have + around a task. Human messages never reach the agent unless the task author + forwards one (send_to_agent), which stamps the forwarded_* fields. Agent rows + (``author_kind=AGENT``, no ``author``) are server-emitted announcements carrying + a stable ``event`` key + ``payload`` — the same shape as ``ChannelFeedMessage`` — + so clients can render them structurally and dedupe them against live + session-derived views (e.g. ``turn_complete`` carries the run id).""" + + class AuthorKind(models.TextChoices): + HUMAN = "human", "Human" + SYSTEM = "system", "System" + AGENT = "agent", "Agent" # nosemgrep: prefer-uuid7-django-pk -- mirrors sibling task models in this app id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -826,6 +835,11 @@ class TaskThreadMessage(TeamScopedRootMixin): author = models.ForeignKey( "posthog.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="+", db_constraint=False ) + author_kind = models.CharField(max_length=16, choices=AuthorKind, default=AuthorKind.HUMAN) + # Stable event key + structured payload for non-human rows (empty for human + # messages); `content` stays the rendered text so older clients degrade cleanly. + event = models.CharField(max_length=64, blank=True, default="") + payload = models.JSONField(default=dict, blank=True) content = models.TextField() forwarded_to_agent_at = models.DateTimeField(null=True, blank=True) forwarded_by = models.ForeignKey( diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 529fe7c12304..5bda37632614 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -1274,7 +1274,18 @@ class TaskThreadMessageSerializer(DataclassSerializer): class Meta: dataclass = TaskThreadMessageDTO - fields = ["id", "task", "content", "created_at", "author", "forwarded_to_agent_at", "forwarded_by"] + fields = [ + "id", + "task", + "author_kind", + "event", + "payload", + "content", + "created_at", + "author", + "forwarded_to_agent_at", + "forwarded_by", + ] class TaskThreadMessageWriteSerializer(serializers.Serializer): diff --git a/products/tasks/backend/tests/test_thread_updates.py b/products/tasks/backend/tests/test_thread_updates.py index 3446867adbe4..07fc4e17667c 100644 --- a/products/tasks/backend/tests/test_thread_updates.py +++ b/products/tasks/backend/tests/test_thread_updates.py @@ -54,6 +54,11 @@ def test_turn_complete_posts_authorless_message_mentioning_creator(self, _name, messages = self._messages(self.task) self.assertEqual(len(messages), 1) self.assertIsNone(messages[0].author_id) + self.assertEqual(messages[0].author_kind, TaskThreadMessage.AuthorKind.AGENT) + self.assertEqual(messages[0].event, "turn_complete") + # run_id is the client's key for deduping this durable row against + # live session-derived agent turns. + self.assertEqual(messages[0].payload, {"run_id": str(self.task_run.id)}) self.assertEqual(messages[0].content, expected) # The creator's mention is indexed so it lands in their mentions feed. self.assertTrue( @@ -125,6 +130,8 @@ def test_canvas_created_message_content(self, _name, canvas_name, canvas_url, ex messages = self._messages(self.task) self.assertEqual(len(messages), 1) self.assertIsNone(messages[0].author_id) + self.assertEqual(messages[0].author_kind, TaskThreadMessage.AuthorKind.AGENT) + self.assertEqual(messages[0].event, "canvas_created") self.assertEqual(messages[0].content, expected) @patch(_FLAG_TARGET, return_value=False) From 25fb21bdfed6eb989a9a87953a2511d0563a77e1 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 13 Jul 2026 13:06:21 +0100 Subject: [PATCH 5/7] fix(tasks): cover ChannelFeedMessage in the IDOR semgrep rule The IDOR model-coverage repo check requires every team-scoped model to appear in the semgrep rule list; ChannelFeedMessage (introduced on the base branch this PR stacks on) was missing, failing repo checks and cancelling the rest of backend CI. Generated-By: PostHog Code Task-Id: ea09a661-1f6d-4bd8-a046-24be60909d28 --- .semgrep/rules/security/idor-team-scoped-models.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.semgrep/rules/security/idor-team-scoped-models.yaml b/.semgrep/rules/security/idor-team-scoped-models.yaml index 8ab093dd0016..3e1ed02c74a7 100644 --- a/.semgrep/rules/security/idor-team-scoped-models.yaml +++ b/.semgrep/rules/security/idor-team-scoped-models.yaml @@ -259,6 +259,7 @@ rules: |TaskThreadMessage |TaskThreadMessageMention |Channel + |ChannelFeedMessage |EmailChannel |EvaluationReport |Text @@ -542,6 +543,7 @@ rules: |TaskThreadMessage |TaskThreadMessageMention |Channel + |ChannelFeedMessage |EmailChannel |EvaluationReport |Text From ca835efc15b0e60825e7da0dcdd44b8e9de76db9 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 13 Jul 2026 13:08:35 +0100 Subject: [PATCH 6/7] fix(tasks): bind canvas thread announcement to the task creator's identity The X-PostHog-Task-Id header only proved same-team membership, so a caller able to publish one canvas could name any same-team task and plant an agent-authored canvas_created message in its thread (flagged by review). The sandbox publishes with the task creator's credentials, so the facade now requires the attributed task to be created by the requesting user and drops the announcement otherwise. Generated-By: PostHog Code Task-Id: ea09a661-1f6d-4bd8-a046-24be60909d28 --- posthog/api/file_system/file_system.py | 12 +++++------ .../file_system/test/test_canvas_publish.py | 20 +++++++++++++++++++ products/tasks/backend/facade/api.py | 13 ++++++++---- .../backend/tests/test_thread_updates.py | 18 +++++++++++++++-- 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/posthog/api/file_system/file_system.py b/posthog/api/file_system/file_system.py index 5d7e2b2946b6..e79f4d0e6e74 100644 --- a/posthog/api/file_system/file_system.py +++ b/posthog/api/file_system/file_system.py @@ -1169,22 +1169,22 @@ def _announce_canvas_created(self, request: Request, dashboard: FileSystem) -> N """Announce a canvas's first publish in the generating task's thread. The task sandbox stamps every MCP call with an X-PostHog-Task-Id header, so - a publish is attributable to the task that made it. Attribution only, not an - authorization boundary: the bearer token already had to be able to write the - canvas, and the header must name a task in the same team. No header (a human - or app save) means no announcement. + a publish is attributable to the task that made it. The sandbox authenticates + with the task creator's credentials, so the facade only accepts a task created + by the requesting user — the header can't point the announcement at someone + else's task thread. No header (a human or app save) means no announcement. """ raw_task_id = (request.headers.get("X-PostHog-Task-Id") or "").strip() try: task_id = UUID(raw_task_id) except ValueError: return - if not tasks_facade.task_exists(task_id, self.team_id): - return + user = request.user if isinstance(request.user, User) else None segments = split_path(dashboard.path) tasks_facade.post_canvas_created_thread_update( task_id, self.team_id, + acting_user_id=user.id if user else None, canvas_name=segments[-1] if segments else "Canvas", canvas_url=self._canvas_share_url(dashboard), ) diff --git a/posthog/api/file_system/test/test_canvas_publish.py b/posthog/api/file_system/test/test_canvas_publish.py index f8578c7ce2cf..d918e22b940d 100644 --- a/posthog/api/file_system/test/test_canvas_publish.py +++ b/posthog/api/file_system/test/test_canvas_publish.py @@ -9,6 +9,7 @@ from rest_framework import status from posthog.models.file_system.file_system import FileSystem +from posthog.models.user import User if TYPE_CHECKING: from products.tasks.backend.models import Task @@ -150,6 +151,25 @@ def test_announcement_links_via_parent_folder_when_meta_has_no_channel(self, _fl message = self._thread_messages(task).get() self.assertTrue(message.content.startswith(f"[MyCanvas]({settings.SITE_URL}/code/canvas/{folder.id}/")) + @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) + def test_header_naming_someone_elses_task_stays_silent(self, _flag): + # The header selects the announcement's thread; it must not let a publisher + # plant agent messages in a task they didn't create. + other = User.objects.create_and_join(self.organization, "other@posthog.com", None) + Task = apps.get_model("tasks", "Task") + task = Task.objects.create( + team=self.team, + title="Someone else's task", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + created_by=other, + ) + item_id = self._create_dashboard() + + self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + + self.assertFalse(self._thread_messages(task).exists()) + def test_publish_without_task_attribution_stays_silent(self): item_id = self._create_dashboard() diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 843c2eef6ae3..696dfd28ba6d 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -5070,18 +5070,23 @@ def _agent_thread_updates_enabled(creator: User | None) -> bool: def post_canvas_created_thread_update( - task_id: str | UUID, team_id: int, *, canvas_name: str, canvas_url: str | None + task_id: str | UUID, team_id: int, *, acting_user_id: int | None, canvas_name: str, canvas_url: str | None ) -> None: """Announce a freshly created canvas in the generating task's thread. - Posts "[name](url) has been created" as an authorless (agent) message. Called - on a canvas's first publish only — the caller owns that once-guard. Best-effort + Posts "[name](url) has been created" as an agent message. Called on a canvas's + first publish only — the caller owns that once-guard. ``acting_user_id`` must be + the task's creator: the sandbox publishes with the creator's credentials, so this + binds the attributed task to the caller's identity — a same-team caller can't + plant agent messages in someone else's task thread by naming its id. Best-effort and never raises: the publish must not fail because its announcement couldn't be written. """ try: task = Task.objects.select_related("created_by").filter(id=task_id, team_id=team_id).first() - if task is None or not _agent_thread_updates_enabled(task.created_by): + if task is None or task.created_by_id is None or task.created_by_id != acting_user_id: + return + if not _agent_thread_updates_enabled(task.created_by): return # Brackets and newlines in the name would break the [label](url) token. name = re.sub(r"[\[\]\n]", " ", canvas_name).strip() or "Canvas" diff --git a/products/tasks/backend/tests/test_thread_updates.py b/products/tasks/backend/tests/test_thread_updates.py index 07fc4e17667c..4f197b309a34 100644 --- a/products/tasks/backend/tests/test_thread_updates.py +++ b/products/tasks/backend/tests/test_thread_updates.py @@ -125,7 +125,9 @@ def test_turn_complete_skips(self, _name, flag_on, has_channel, has_creator, fla ) @patch(_FLAG_TARGET, return_value=True) def test_canvas_created_message_content(self, _name, canvas_name, canvas_url, expected, _flag) -> None: - post_canvas_created_thread_update(self.task.id, self.team.id, canvas_name=canvas_name, canvas_url=canvas_url) + post_canvas_created_thread_update( + self.task.id, self.team.id, acting_user_id=self.user.id, canvas_name=canvas_name, canvas_url=canvas_url + ) messages = self._messages(self.task) self.assertEqual(len(messages), 1) @@ -134,9 +136,21 @@ def test_canvas_created_message_content(self, _name, canvas_name, canvas_url, ex self.assertEqual(messages[0].event, "canvas_created") self.assertEqual(messages[0].content, expected) + @patch(_FLAG_TARGET, return_value=True) + def test_canvas_created_requires_creator_match(self, _flag) -> None: + other = User.objects.create_user(email="other@example.com", first_name="Other", password="password") + + post_canvas_created_thread_update( + self.task.id, self.team.id, acting_user_id=other.id, canvas_name="Canvas", canvas_url=None + ) + + self.assertEqual(self._messages(self.task), []) + @patch(_FLAG_TARGET, return_value=False) def test_canvas_created_skips_when_flag_off(self, _flag) -> None: - post_canvas_created_thread_update(self.task.id, self.team.id, canvas_name="Canvas", canvas_url=None) + post_canvas_created_thread_update( + self.task.id, self.team.id, acting_user_id=self.user.id, canvas_name="Canvas", canvas_url=None + ) self.assertEqual(self._messages(self.task), []) From 5f38e68c6b956ee55eea74398dfe11dfbf25123e Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:30:45 +0000 Subject: [PATCH 7/7] chore: update OpenAPI generated types --- .../tasks/frontend/generated/api.schemas.ts | 67 ++++++++++++++++++ products/tasks/frontend/generated/api.ts | 64 +++++++++++++++++ products/tasks/frontend/generated/api.zod.ts | 27 ++++++++ products/tasks/mcp/tools.yaml | 6 ++ services/mcp/src/api/generated.ts | 68 +++++++++++++++++++ 5 files changed, 232 insertions(+) diff --git a/products/tasks/frontend/generated/api.schemas.ts b/products/tasks/frontend/generated/api.schemas.ts index aa994c41c87c..47f59325908b 100644 --- a/products/tasks/frontend/generated/api.schemas.ts +++ b/products/tasks/frontend/generated/api.schemas.ts @@ -414,6 +414,57 @@ export interface ChannelWriteApi { name: string } +export type ChannelFeedMessageDTOApiPayload = { [key: string]: unknown } + +/** + * Response shape for one system announcement in a channel's feed. + */ +export interface ChannelFeedMessageDTOApi { + id: string + channel: string + author?: TaskUserBasicInfoApi | null + author_kind: string + event: string + payload: ChannelFeedMessageDTOApiPayload + content: string + created_at: string +} + +export interface PaginatedChannelFeedMessageDTOListApi { + count: number + /** @nullable */ + next?: string | null + /** @nullable */ + previous?: string | null + results: ChannelFeedMessageDTOApi[] +} + +/** + * * `context_created` - context_created + * * `context_md_building` - context_md_building + */ +export type EventEnumApi = (typeof EventEnumApi)[keyof typeof EventEnumApi] + +export const EventEnumApi = { + ContextCreated: 'context_created', + ContextMdBuilding: 'context_md_building', +} as const + +/** + * Request body for posting a system announcement into a channel's feed. + */ +export interface ChannelFeedMessageWriteApi { + /** Lifecycle event key. + * + * * `context_created` - context_created + * * `context_md_building` - context_md_building */ + event: EventEnumApi + /** Structured event data, e.g. {"context_name": "mobile"}. */ + payload?: unknown + /** Optional explicit timestamp (within 10 minutes of now), so a client can order a burst of announcements. */ + created_at?: string +} + /** * Request body for creating (resolve-or-create) or renaming a public channel. */ @@ -2183,12 +2234,17 @@ export interface TaskRunLivingArtifactEditRequestApi { metadata?: TaskRunLivingArtifactEditRequestApiMetadata } +export type TaskThreadMessageDTOApiPayload = { [key: string]: unknown } + /** * Response shape for one message in a task's thread. */ export interface TaskThreadMessageDTOApi { id: string task: string + author_kind: string + event: string + payload: TaskThreadMessageDTOApiPayload content: string created_at: string author?: TaskUserBasicInfoApi | null @@ -2618,6 +2674,17 @@ export type TaskChannelsListParams = { offset?: number } +export type TaskChannelsFeedListParams = { + /** + * Number of results to return per page. + */ + limit?: number + /** + * The initial index from which to return the results. + */ + offset?: number +} + export type TaskMentionsListParams = { /** * Maximum number of mentions to return (newest first). diff --git a/products/tasks/frontend/generated/api.ts b/products/tasks/frontend/generated/api.ts index 6fdc10d93dda..ed261a55b7d3 100644 --- a/products/tasks/frontend/generated/api.ts +++ b/products/tasks/frontend/generated/api.ts @@ -10,10 +10,13 @@ import { apiMutator } from '../../../../frontend/src/lib/api-orval-mutator' */ import type { ChannelDTOApi, + ChannelFeedMessageDTOApi, + ChannelFeedMessageWriteApi, ChannelWriteApi, CodeInviteRedeemRequestApi, ConnectionTokenResponseApi, PaginatedChannelDTOListApi, + PaginatedChannelFeedMessageDTOListApi, PaginatedSandboxCustomImageDTOListApi, PaginatedSandboxEnvironmentDTOListApi, PaginatedTaskAutomationDTOListApi, @@ -41,6 +44,7 @@ import type { TaskAutomationDTOApi, TaskAutomationWriteApi, TaskAutomationsListParams, + TaskChannelsFeedListParams, TaskChannelsListParams, TaskDetailDTOApi, TaskMentionsListParams, @@ -533,6 +537,66 @@ export const taskChannelsCreate = async ( }) } +export const getTaskChannelsFeedListUrl = ( + projectId: string, + channelId: string, + params?: TaskChannelsFeedListParams +) => { + const normalizedParams = new URLSearchParams() + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)) + } + }) + + const stringifiedParams = normalizedParams.toString() + + return stringifiedParams.length > 0 + ? `/api/projects/${projectId}/task_channels/${channelId}/feed/?${stringifiedParams}` + : `/api/projects/${projectId}/task_channels/${channelId}/feed/` +} + +/** + * A channel's system announcements in chronological order. + * @summary List channel feed messages + */ +export const taskChannelsFeedList = async ( + projectId: string, + channelId: string, + params?: TaskChannelsFeedListParams, + options?: RequestInit +): Promise => { + return apiMutator(getTaskChannelsFeedListUrl(projectId, channelId, params), { + ...options, + method: 'GET', + }) +} + +export const getTaskChannelsFeedCreateUrl = (projectId: string, channelId: string) => { + return `/api/projects/${projectId}/task_channels/${channelId}/feed/` +} + +/** + * API for a channel's system-announcement feed — durable "PostHog agent" rows + * (context created, CONTEXT.md being built) rendered alongside the channel's task + * cards. Read by any team member for a public channel; personal channels are owner-only. + * @summary Post a channel feed message + */ +export const taskChannelsFeedCreate = async ( + projectId: string, + channelId: string, + channelFeedMessageWriteApi: ChannelFeedMessageWriteApi, + options?: RequestInit +): Promise => { + return apiMutator(getTaskChannelsFeedCreateUrl(projectId, channelId), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(channelFeedMessageWriteApi), + }) +} + export const getTaskChannelsPartialUpdateUrl = (projectId: string, id: string) => { return `/api/projects/${projectId}/task_channels/${id}/` } diff --git a/products/tasks/frontend/generated/api.zod.ts b/products/tasks/frontend/generated/api.zod.ts index 4039b5334460..1620c2a7f8ce 100644 --- a/products/tasks/frontend/generated/api.zod.ts +++ b/products/tasks/frontend/generated/api.zod.ts @@ -294,6 +294,30 @@ export const TaskChannelsCreateBody = /* @__PURE__ */ zod }) .describe('Request body for creating (resolve-or-create) or renaming a public channel.') +/** + * API for a channel's system-announcement feed — durable "PostHog agent" rows + * (context created, CONTEXT.md being built) rendered alongside the channel's task + * cards. Read by any team member for a public channel; personal channels are owner-only. + * @summary Post a channel feed message + */ +export const TaskChannelsFeedCreateBody = /* @__PURE__ */ zod + .object({ + event: zod + .enum(['context_created', 'context_md_building']) + .describe('\* `context_created` - context_created\n\* `context_md_building` - context_md_building') + .describe( + 'Lifecycle event key.\n\n\* `context_created` - context_created\n\* `context_md_building` - context_md_building' + ), + payload: zod.unknown().optional().describe('Structured event data, e.g. {\"context_name\": \"mobile\"}.'), + created_at: zod.iso + .datetime({ offset: true }) + .optional() + .describe( + 'Optional explicit timestamp (within 10 minutes of now), so a client can order a burst of announcements.' + ), + }) + .describe("Request body for posting a system announcement into a channel's feed.") + /** * API for task channels — the shared feeds tasks are kicked off in. Listing lazily * provisions the requester's personal "#me" channel; creation is resolve-or-create @@ -1841,6 +1865,9 @@ export const TasksThreadMessagesSendToAgentCreateBody = /* @__PURE__ */ zod .object({ id: zod.uuid(), task: zod.uuid(), + author_kind: zod.string(), + event: zod.string(), + payload: zod.record(zod.string(), zod.unknown()), content: zod.string(), created_at: zod.iso.datetime({ offset: true }), author: zod diff --git a/products/tasks/mcp/tools.yaml b/products/tasks/mcp/tools.yaml index f51e0102ed8e..40d8d18c226d 100644 --- a/products/tasks/mcp/tools.yaml +++ b/products/tasks/mcp/tools.yaml @@ -72,6 +72,12 @@ tools: task-channels-destroy: operation: task_channels_destroy enabled: false + task-channels-feed-create: + operation: task_channels_feed_create + enabled: false + task-channels-feed-list: + operation: task_channels_feed_list + enabled: false task-channels-list: operation: task_channels_list enabled: false diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index ba4aa022c4d4..e0a6acaee5b0 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -12939,6 +12939,49 @@ export namespace Schemas { GithubIssue: 'github_issue', } as const; + export type ChannelFeedMessageDTOPayload = { [key: string]: unknown }; + + /** + * Response shape for one system announcement in a channel's feed. + */ + export interface ChannelFeedMessageDTO { + id: string; + channel: string; + author?: TaskUserBasicInfo | null; + author_kind: string; + event: string; + payload: ChannelFeedMessageDTOPayload; + content: string; + created_at: string; + } + + /** + * * `context_created` - context_created + * * `context_md_building` - context_md_building + */ + export type EventEnum = typeof EventEnum[keyof typeof EventEnum]; + + + export const EventEnum = { + ContextCreated: 'context_created', + ContextMdBuilding: 'context_md_building', + } as const; + + /** + * Request body for posting a system announcement into a channel's feed. + */ + export interface ChannelFeedMessageWrite { + /** Lifecycle event key. + * + * * `context_created` - context_created + * * `context_md_building` - context_md_building */ + event: EventEnum; + /** Structured event data, e.g. {"context_name": "mobile"}. */ + payload?: unknown; + /** Optional explicit timestamp (within 10 minutes of now), so a client can order a burst of announcements. */ + created_at?: string; + } + /** * * `widget` - Widget * * `email` - Email @@ -33783,6 +33826,15 @@ export namespace Schemas { results: ChannelDTO[]; } + export interface PaginatedChannelFeedMessageDTOList { + count: number; + /** @nullable */ + next?: string | null; + /** @nullable */ + previous?: string | null; + results: ChannelFeedMessageDTO[]; + } + export interface PaginatedClickhouseEventList { /** @nullable */ next?: string | null; @@ -37261,12 +37313,17 @@ export namespace Schemas { results: TaskSummaryDTO[]; } + export type TaskThreadMessageDTOPayload = { [key: string]: unknown }; + /** * Response shape for one message in a task's thread. */ export interface TaskThreadMessageDTO { id: string; task: string; + author_kind: string; + event: string; + payload: TaskThreadMessageDTOPayload; content: string; created_at: string; author?: TaskUserBasicInfo | null; @@ -72221,6 +72278,17 @@ export namespace Schemas { offset?: number; }; + export type TaskChannelsFeedListParams = { + /** + * Number of results to return per page. + */ + limit?: number; + /** + * The initial index from which to return the results. + */ + offset?: number; + }; + export type TaskMentionsListParams = { /** * Maximum number of mentions to return (newest first).