Skip to content

Commit 25490dd

Browse files
feat(tasks): post agent thread updates for canvas creation and turn completion (#70371)
Co-authored-by: tests-posthog[bot] <250237707+tests-posthog[bot]@users.noreply.github.com>
1 parent 19f49c6 commit 25490dd

17 files changed

Lines changed: 798 additions & 9 deletions

File tree

.semgrep/rules/security/idor-team-scoped-models.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ rules:
259259
|TaskThreadMessage
260260
|TaskThreadMessageMention
261261
|Channel
262+
|ChannelFeedMessage
262263
|EmailChannel
263264
|EvaluationReport
264265
|Text
@@ -542,6 +543,7 @@ rules:
542543
|TaskThreadMessage
543544
|TaskThreadMessageMention
544545
|Channel
546+
|ChannelFeedMessage
545547
|EmailChannel
546548
|EvaluationReport
547549
|Text

posthog/api/file_system/file_system.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import shlex
44
import builtins
55
from typing import Any, cast
6-
from uuid import uuid4
6+
from uuid import UUID, uuid4
77

8+
from django.conf import settings
89
from django.db import transaction
910
from django.db.models import Case, F, IntegerField, Q, QuerySet, Value, When
1011
from django.db.models.functions import Concat, Lower
@@ -63,6 +64,8 @@
6364
from posthog.models.user import User
6465
from posthog.utils import str_to_bool
6566

67+
from products.tasks.backend.facade import api as tasks_facade
68+
6669
DELETE_PREVIEW_ENTRY_LIMIT = 200
6770

6871
# 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
11321135
if isinstance(existing_context, str):
11331136
version["context"] = existing_context
11341137
versions = list(meta.get("versions") or [])
1138+
first_publish = not versions and not meta.get("code")
11351139
versions.append(version)
11361140

11371141
meta.update(
@@ -1156,8 +1160,59 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons
11561160

11571161
dashboard.save(update_fields=update_fields)
11581162

1163+
if first_publish:
1164+
self._announce_canvas_created(request, dashboard)
1165+
11591166
return Response(self.get_serializer(dashboard).data)
11601167

1168+
def _announce_canvas_created(self, request: Request, dashboard: FileSystem) -> None:
1169+
"""Announce a canvas's first publish in the generating task's thread.
1170+
1171+
The task sandbox stamps every MCP call with an X-PostHog-Task-Id header, so
1172+
a publish is attributable to the task that made it. The sandbox authenticates
1173+
with the task creator's credentials, so the facade only accepts a task created
1174+
by the requesting user — the header can't point the announcement at someone
1175+
else's task thread. No header (a human or app save) means no announcement.
1176+
"""
1177+
raw_task_id = (request.headers.get("X-PostHog-Task-Id") or "").strip()
1178+
try:
1179+
task_id = UUID(raw_task_id)
1180+
except ValueError:
1181+
return
1182+
user = request.user if isinstance(request.user, User) else None
1183+
segments = split_path(dashboard.path)
1184+
tasks_facade.post_canvas_created_thread_update(
1185+
task_id,
1186+
self.team_id,
1187+
acting_user_id=user.id if user else None,
1188+
canvas_name=segments[-1] if segments else "Canvas",
1189+
canvas_url=self._canvas_share_url(dashboard),
1190+
)
1191+
1192+
def _canvas_share_url(self, dashboard: FileSystem) -> str | None:
1193+
"""The web interstitial link that deep-links into the desktop app's canvas view:
1194+
`/code/canvas/<channel folder id>/<dashboard id>`. The channel id is stamped on
1195+
the row's meta by the desktop app at create time; fall back to the parent folder
1196+
row for rows that predate the stamp.
1197+
"""
1198+
channel_id = (dashboard.meta or {}).get("channelId")
1199+
if not channel_id:
1200+
parent_path = join_path(split_path(dashboard.path)[:-1])
1201+
folder = (
1202+
FileSystem.objects.filter(
1203+
surface_q(self.file_system_surface),
1204+
team_id=dashboard.team_id,
1205+
type="folder",
1206+
path=parent_path,
1207+
).first()
1208+
if parent_path
1209+
else None
1210+
)
1211+
channel_id = str(folder.id) if folder else None
1212+
if not channel_id:
1213+
return None
1214+
return f"{settings.SITE_URL}/code/canvas/{channel_id}/{dashboard.id}"
1215+
11611216
@extend_schema(responses={200: FolderInstructionsSerializer})
11621217
@action(methods=["GET"], detail=True)
11631218
def instructions(self, request: Request, *args: Any, **kwargs: Any) -> Response:

posthog/api/file_system/test/test_canvas_publish.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
from typing import cast
1+
from typing import TYPE_CHECKING, cast
22

33
from posthog.test.base import APIBaseTest
4+
from unittest.mock import patch
5+
6+
from django.apps import apps
7+
from django.conf import settings
48

59
from rest_framework import status
610

711
from posthog.models.file_system.file_system import FileSystem
12+
from posthog.models.user import User
13+
14+
if TYPE_CHECKING:
15+
from products.tasks.backend.models import Task
816

917

1018
class TestDesktopCanvasPublishAPI(APIBaseTest):
@@ -96,6 +104,80 @@ def test_publish_canvas_requires_code(self):
96104
self.assertEqual(bad.status_code, status.HTTP_400_BAD_REQUEST, bad.json())
97105
self.assertIn("code", bad.json())
98106

107+
# Task models load via the app registry: this test lives outside the isolated
108+
# tasks product, so it can't import its internals (tach-enforced).
109+
def _create_task(self) -> "Task":
110+
Task = apps.get_model("tasks", "Task")
111+
return Task.objects.create(
112+
team=self.team,
113+
title="Generate canvas",
114+
description="",
115+
origin_product=Task.OriginProduct.USER_CREATED,
116+
created_by=self.user,
117+
)
118+
119+
def _thread_messages(self, task: "Task"):
120+
TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage")
121+
return TaskThreadMessage.objects.for_team(self.team.id).filter(task=task)
122+
123+
@patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True)
124+
def test_first_publish_from_task_announces_in_thread_once(self, _flag):
125+
task = self._create_task()
126+
item_id = self._create_dashboard(meta={"channelId": "chan-1"})
127+
128+
self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id))
129+
130+
messages = self._thread_messages(task)
131+
self.assertEqual(messages.count(), 1)
132+
message = messages.get()
133+
self.assertIsNone(message.author_id)
134+
self.assertEqual(
135+
message.content,
136+
f"[MyCanvas]({settings.SITE_URL}/code/canvas/chan-1/{item_id}) has been created",
137+
)
138+
139+
# A second publish updates the canvas, it doesn't create it again.
140+
self.client.patch(self._canvas_url(item_id), {"code": "v2"}, HTTP_X_POSTHOG_TASK_ID=str(task.id))
141+
self.assertEqual(messages.count(), 1)
142+
143+
@patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True)
144+
def test_announcement_links_via_parent_folder_when_meta_has_no_channel(self, _flag):
145+
task = self._create_task()
146+
item_id = self._create_dashboard() # no channelId stamp — rows created before the app stamped it
147+
148+
self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id))
149+
150+
folder = FileSystem.objects.get(team=self.team, path="MyChannel", type="folder")
151+
message = self._thread_messages(task).get()
152+
self.assertTrue(message.content.startswith(f"[MyCanvas]({settings.SITE_URL}/code/canvas/{folder.id}/"))
153+
154+
@patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True)
155+
def test_header_naming_someone_elses_task_stays_silent(self, _flag):
156+
# The header selects the announcement's thread; it must not let a publisher
157+
# plant agent messages in a task they didn't create.
158+
other = User.objects.create_and_join(self.organization, "other@posthog.com", None)
159+
Task = apps.get_model("tasks", "Task")
160+
task = Task.objects.create(
161+
team=self.team,
162+
title="Someone else's task",
163+
description="",
164+
origin_product=Task.OriginProduct.USER_CREATED,
165+
created_by=other,
166+
)
167+
item_id = self._create_dashboard()
168+
169+
self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id))
170+
171+
self.assertFalse(self._thread_messages(task).exists())
172+
173+
def test_publish_without_task_attribution_stays_silent(self):
174+
item_id = self._create_dashboard()
175+
176+
self.client.patch(self._canvas_url(item_id), {"code": "v1"})
177+
178+
TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage")
179+
self.assertFalse(TaskThreadMessage.objects.for_team(self.team.id).exists())
180+
99181
def test_delete_canvas_removes_ref_less_dashboard_row(self):
100182
# Desktop canvases are `dashboard`-typed rows with no ref; deleting one must not
101183
# trip the "without a reference" guard meant for real object-backed rows.

products/tasks/backend/facade/api.py

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from uuid import UUID, uuid4
2525

2626
from django.conf import settings
27-
from django.db import IntegrityError, transaction
27+
from django.db import IntegrityError, close_old_connections, transaction
2828
from django.db.models import CharField, Count, Exists, F, Min, OuterRef, Q, QuerySet, Subquery
2929
from django.db.models.fields.json import KeyTextTransform
3030
from django.utils import timezone as django_timezone
@@ -49,7 +49,7 @@
4949
is_custom_images_enabled,
5050
read_spec_from_builder_sandbox,
5151
)
52-
from products.tasks.backend.mentions import resolve_mentioned_user_ids
52+
from products.tasks.backend.mentions import format_mention_token, resolve_mentioned_user_ids
5353
from products.tasks.backend.models import (
5454
Channel,
5555
ChannelFeedMessage,
@@ -4863,6 +4863,9 @@ def _thread_message_to_dto(message: TaskThreadMessage) -> contracts.TaskThreadMe
48634863
return contracts.TaskThreadMessageDTO(
48644864
id=message.id,
48654865
task=message.task_id,
4866+
author_kind=message.author_kind,
4867+
event=message.event,
4868+
payload=message.payload or {},
48664869
content=message.content,
48674870
created_at=message.created_at,
48684871
author=_user_basic_info(message.author if message.author_id else None),
@@ -5018,6 +5021,127 @@ def forward_thread_message(
50185021
return "ok", _thread_message_to_dto(message)
50195022

50205023

5024+
# Threads are a Channels (project-bluebird) surface, so agent-authored thread
5025+
# updates are gated on the same flag — evaluated for the task creator.
5026+
AGENT_THREAD_UPDATES_FLAG = "project-bluebird"
5027+
5028+
# One turn-complete post per run within the window, so an SSE relay reconnect
5029+
# replaying the tail of the stream can't double-post the same end-of-turn.
5030+
_TURN_COMPLETE_COOLDOWN_SECONDS = 30
5031+
5032+
# Cap the relayed final message so one agent essay can't dwarf the thread.
5033+
_TURN_MESSAGE_MAX_CHARS = 4000
5034+
5035+
5036+
def _create_agent_thread_message(task: Task, content: str, *, event: str, payload: dict | None = None) -> None:
5037+
"""Write an agent-authored thread message and index its mentions.
5038+
5039+
``content`` is the rendered text (older clients show it as-is); ``event`` +
5040+
``payload`` are the structured record, mirroring ChannelFeedMessage, that
5041+
lets clients render agent rows natively and dedupe them against live views.
5042+
"""
5043+
message = TaskThreadMessage.objects.create(
5044+
team_id=task.team_id,
5045+
task_id=task.id,
5046+
author_id=None,
5047+
author_kind=TaskThreadMessage.AuthorKind.AGENT,
5048+
event=event,
5049+
payload=payload or {},
5050+
content=content,
5051+
)
5052+
try:
5053+
_index_thread_message_mentions(message)
5054+
except Exception:
5055+
logger.exception("Failed to index thread message mentions", extra={"message_id": str(message.id)})
5056+
5057+
5058+
def _agent_thread_updates_enabled(creator: User | None) -> bool:
5059+
"""Fail closed: no creator to key the flag on, or a flag-service error, means no post."""
5060+
if creator is None:
5061+
return False
5062+
distinct_id = creator.distinct_id or f"user_{creator.id}"
5063+
try:
5064+
return bool(
5065+
posthoganalytics.feature_enabled(AGENT_THREAD_UPDATES_FLAG, distinct_id, send_feature_flag_events=False)
5066+
)
5067+
except Exception:
5068+
logger.warning("Agent thread update flag check failed", extra={"user_id": creator.id}, exc_info=True)
5069+
return False
5070+
5071+
5072+
def post_canvas_created_thread_update(
5073+
task_id: str | UUID, team_id: int, *, acting_user_id: int | None, canvas_name: str, canvas_url: str | None
5074+
) -> None:
5075+
"""Announce a freshly created canvas in the generating task's thread.
5076+
5077+
Posts "[name](url) has been created" as an agent message. Called on a canvas's
5078+
first publish only — the caller owns that once-guard. ``acting_user_id`` must be
5079+
the task's creator: the sandbox publishes with the creator's credentials, so this
5080+
binds the attributed task to the caller's identity — a same-team caller can't
5081+
plant agent messages in someone else's task thread by naming its id. Best-effort
5082+
and never raises: the publish must not fail because its announcement couldn't
5083+
be written.
5084+
"""
5085+
try:
5086+
task = Task.objects.select_related("created_by").filter(id=task_id, team_id=team_id).first()
5087+
if task is None or task.created_by_id is None or task.created_by_id != acting_user_id:
5088+
return
5089+
if not _agent_thread_updates_enabled(task.created_by):
5090+
return
5091+
# Brackets and newlines in the name would break the [label](url) token.
5092+
name = re.sub(r"[\[\]\n]", " ", canvas_name).strip() or "Canvas"
5093+
content = f"[{name}]({canvas_url}) has been created" if canvas_url else f"{name} has been created"
5094+
_create_agent_thread_message(
5095+
task,
5096+
content,
5097+
event="canvas_created",
5098+
payload={"canvas_name": name, "canvas_url": canvas_url},
5099+
)
5100+
except Exception:
5101+
logger.exception("Failed to post canvas-created thread update", extra={"task_id": str(task_id)})
5102+
5103+
5104+
def post_turn_complete_thread_update(
5105+
run_id: str | UUID, task_id: str | UUID, team_id: int, *, message: str | None = None
5106+
) -> None:
5107+
"""Post the agent's final turn message into the task's thread, @-mentioning the task creator.
5108+
5109+
Fires from the sandbox event relay on every end-of-turn of a channel task's
5110+
background run, so the update lands even with no client open. ``message`` is
5111+
the agent's closing prose for the turn; when the relay captured none, a plain
5112+
"Turn complete." stands in. Best-effort and never raises — a failed post must
5113+
not disturb the relay.
5114+
"""
5115+
try:
5116+
if not settings.TEST:
5117+
close_old_connections()
5118+
task = Task.objects.select_related("created_by").filter(id=task_id, team_id=team_id).first()
5119+
# Threads hang off a task's channel feed; a channel-less task has no audience.
5120+
if task is None or task.channel_id is None:
5121+
return
5122+
creator = task.created_by
5123+
if creator is None or not _agent_thread_updates_enabled(creator):
5124+
return
5125+
from products.tasks.backend.redis import get_tasks_cache # noqa: PLC0415 — keep redis off the api import path
5126+
5127+
if not get_tasks_cache().add(f"thread_update:{run_id}:turn_complete", True, _TURN_COMPLETE_COOLDOWN_SECONDS):
5128+
return
5129+
body = (message or "").strip() or "Turn complete."
5130+
if len(body) > _TURN_MESSAGE_MAX_CHARS:
5131+
body = body[: _TURN_MESSAGE_MAX_CHARS - 1] + "…"
5132+
mention = format_mention_token(creator.get_full_name() or creator.email, creator.email)
5133+
# payload.run_id is the dedupe key: a client already rendering this run's
5134+
# live agent turns can suppress the durable row (or vice versa).
5135+
_create_agent_thread_message(
5136+
task,
5137+
f"{mention} {body}",
5138+
event="turn_complete",
5139+
payload={"run_id": str(run_id)},
5140+
)
5141+
except Exception:
5142+
logger.exception("Failed to post turn-complete thread update", extra={"task_id": str(task_id)})
5143+
5144+
50215145
def respond_to_permission_request(
50225146
run_id: str | UUID,
50235147
task_id: str | UUID,

products/tasks/backend/facade/contracts.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ class TaskThreadMessageDTO:
153153

154154
id: UUID
155155
task: UUID
156+
author_kind: str
157+
event: str
158+
payload: dict
156159
content: str
157160
created_at: datetime
158161
author: "TaskUserBasicInfo | None" = None

products/tasks/backend/mentions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ def extract_mention_emails(content: str) -> set[str]:
2222
return {match.group(1).lower() for match in MENTION_TOKEN_PATTERN.finditer(content)}
2323

2424

25+
def format_mention_token(name: str, email: str) -> str:
26+
"""Serialize a user reference into the inline mention token.
27+
28+
Brackets and newlines would break token parsing; the email is the identity,
29+
so the name falls back to its local part when unusable.
30+
"""
31+
safe_name = re.sub(r"[\[\]\n]", " ", name).strip() or email.split("@")[0] or email
32+
return f"@[{safe_name}]({email})"
33+
34+
2535
def resolve_mentioned_user_ids(user_model: Any, content: str, *, team_id: int, author_id: int | None) -> list[int]:
2636
"""Ids of the team's org members mentioned in the content, excluding the author.
2737

0 commit comments

Comments
 (0)