Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions posthog/api/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,29 @@ def _require_ticket_editor_access(
raise exceptions.PermissionDenied("You do not have access to this ticket")


def _record_task_comment_activity(comment: Comment, mentions: list[int]) -> None:
"""Mirror mentions on a Code task's comments into that task's activity feed.

The desktop app reads its own activity feed rather than the notifications inbox, so a
mention that only fanned out to email and the web would be invisible in the very app
the comment was written in. The tasks facade decides which scopes are its own.
"""
from products.tasks.backend.facade.api import ( # noqa: PLC0415 — keeps the generic comments API decoupled from the tasks product, only imported for mention writes
record_comment_mention_activity,
)

record_comment_mention_activity(
team_id=comment.team_id,
scope=comment.scope,
item_id=comment.item_id,
item_context=comment.item_context,
comment_id=comment.id,
author_id=comment.created_by_id,
created_at=comment.created_at,
mentioned_user_ids=mentions,
)


class CommentSerializer(serializers.ModelSerializer):
def _extract_mentions_from_rich_content(self, rich_content: dict | None) -> list[int]:
if not rich_content:
Expand Down Expand Up @@ -195,6 +218,7 @@ def create(self, validated_data: Any) -> Any:
send_discussions_mentioned.delay(comment.id, mentions, slug)
produce_discussion_mention_events(comment, mentions, slug)
send_mention_notifications(comment, mentions, slug)
_record_task_comment_activity(comment, mentions)

return comment

Expand Down Expand Up @@ -225,6 +249,7 @@ def update(self, instance: Comment, validated_data: dict, **kwargs: Any) -> Comm
send_discussions_mentioned.delay(updated_instance.id, mentions, slug)
produce_discussion_mention_events(updated_instance, mentions, slug)
send_mention_notifications(updated_instance, mentions, slug)
_record_task_comment_activity(updated_instance, mentions)

return updated_instance

Expand Down
105 changes: 101 additions & 4 deletions products/tasks/backend/facade/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
is_blocked_sandbox_env_key,
)
from products.tasks.backend.error_telemetry import truncate_error_message
from products.tasks.backend.forwarded_content import frame_forwarded_comment
from products.tasks.backend.logic.services.image_builder import (
ensure_image_builder_task,
is_custom_images_enabled,
Expand Down Expand Up @@ -5597,6 +5598,80 @@ def _index_thread_message_mentions(message: TaskThreadMessage) -> None:
)


# The comment scopes the desktop client writes against a task's resources; mirrors its
# CommentScope union. Anything else on the shared comments table belongs to another product.
COMMENT_ACTIVITY_SCOPES = frozenset({"task", "task_artifact", "desktop_canvas"})


def _comment_task_id(scope: str, item_id: str | None, item_context: dict[str, Any] | None) -> UUID | None:
"""The task a comment was written against, or None if it isn't one of ours.

Only ``scope="task"`` names the task directly. The resource-scoped comments carry it in
``item_context`` instead, because their ``item_id`` points at an artifact that lives in a
run's JSON rather than in a table this could join against — so the value is
client-supplied, and callers check it against the team before trusting it.
"""
if scope not in COMMENT_ACTIVITY_SCOPES:
return None
raw_task_id = item_id if scope == "task" else (item_context or {}).get("taskId")
if not isinstance(raw_task_id, str):
return None
try:
return UUID(raw_task_id)
except ValueError:
return None


def record_comment_mention_activity(
*,
team_id: int,
scope: str,
item_id: str | None,
item_context: dict[str, Any] | None,
comment_id: UUID,
author_id: int | None,
created_at: datetime,
mentioned_user_ids: Sequence[int],
) -> None:
"""Project mentions on a task's comments into the mentioned users' activity feeds.

Comments on a task's artifacts and canvases live on the shared comments table rather
than in the task thread, so without this they reach a recipient by email and web
notification but never by the Code app's Activity page — the one surface where the
comment itself is readable.

The task id is client-supplied for resource-scoped comments, so it is checked against
the team before anything is written. Visibility deliberately is not checked: the feed
re-checks it on read, which is what keeps rows honest when a task's visibility changes
after the mention was recorded.
"""
recipients = [user_id for user_id in dict.fromkeys(mentioned_user_ids) if user_id != author_id]
if not recipients:
return

task_id = _comment_task_id(scope, item_id, item_context)
if task_id is None:
return

try:
if not Task.objects.filter(team_id=team_id, id=task_id, deleted=False).exists():
return
for user_id in recipients:
TaskActivity.record(
team_id=team_id,
user_id=user_id,
task_id=task_id,
kind=TaskActivity.Kind.MENTION,
activity_at=created_at,
comment_id=comment_id,
actor_id=author_id,
)
except Exception:
# Best-effort, like the sibling mention fan-outs: a feed row is never worth
# failing the write of the comment that produced it.
logger.exception("Failed to record comment mention activity", extra={"comment_id": str(comment_id)})


def list_mentions(
team_id: int, user_id: int | None, *, since: datetime | None = None, limit: int = 100
) -> list[contracts.TaskMentionDTO]:
Expand Down Expand Up @@ -5678,6 +5753,24 @@ def project_completed_activity(task_run: "TaskRun") -> None:
)


def _activity_snippet(row: TaskActivity) -> str:
"""Preview of whatever the row's latest activity was said in, if anything was."""
if row.message:
return row.message.content
if row.comment:
return row.comment.content or ""
return ""


def _activity_author(row: TaskActivity) -> "User | None":
"""Who wrote the thread message or comment behind the row; None for agent and task rows."""
if row.message:
return row.message.author if row.message.author_id else None
if row.comment:
return row.comment.created_by
return None


def _task_activity_qs(team_id: int, user_id: int) -> QuerySet[TaskActivity]:
"""The requester's feed rows, gated to tasks they can still see.

Expand Down Expand Up @@ -5712,7 +5805,11 @@ def list_task_activity(
qs = _task_activity_qs(team_id, user_id)
if before is not None and before_id is not None:
qs = qs.filter(Q(activity_at__lt=before) | Q(activity_at=before, id__lt=before_id))
rows = list(qs.select_related("task__channel", "message__author").order_by("-activity_at", "-id")[: limit + 1])
rows = list(
qs.select_related("task__channel", "message__author", "comment__created_by").order_by("-activity_at", "-id")[
: limit + 1
]
)
has_more = len(rows) > limit
rows = rows[:limit]
next_row = rows[-1] if has_more else None
Expand All @@ -5726,8 +5823,8 @@ def list_task_activity(
channel_name=row.task.channel.name if row.task.channel else None,
activity_at=row.activity_at,
activity_kind=row.kind,
snippet=row.message.content if row.message else "",
latest_author=_user_basic_info(row.message.author if row.message and row.message.author_id else None),
snippet=_activity_snippet(row),
latest_author=_user_basic_info(_activity_author(row)),
latest_message_id=row.message_id,
is_unread=row.read_at is None,
)
Expand Down Expand Up @@ -5799,7 +5896,7 @@ def forward_thread_message(

author = message.author
author_name = (author.get_full_name() or author.email) if author else "A teammate"
content = f"[Thread comment from {author_name}] {message.content}"
content = frame_forwarded_comment(author_name=author_name, content=message.content)
signal_result = signal_task_run_user_message(run.id, task.id, team_id, content=content, artifact_ids=[])
if not signal_result:
return "signal_failed", None
Expand Down
26 changes: 26 additions & 0 deletions products/tasks/backend/forwarded_content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Framing for human text that gets forwarded into a running agent.

Thread messages and comments are written by whoever can reach the task, then handed to an
agent that reads its whole prompt as instructions. Concatenating them behind a plain prefix
lets a message read as a directive, so they go in a labelled block instead — the same shape
the client already uses for channel context and custom instructions.
"""

import re

FORWARDED_COMMENT_TAG = "forwarded_comment"

# Anything that could close the block early, so the rest of a message can't escape it and
# be read as instructions.
_TAG_PATTERN = re.compile(rf"<\s*/?\s*{FORWARDED_COMMENT_TAG}\b[^>]*>", re.IGNORECASE)
_ATTRIBUTE_UNSAFE = re.compile(r'[<>"\r\n]')


def _attribute(value: str) -> str:
return _ATTRIBUTE_UNSAFE.sub(" ", value).strip()


def frame_forwarded_comment(*, author_name: str, content: str) -> str:
"""Wrap a person's words in a delimited block naming who wrote them."""
body = _TAG_PATTERN.sub("", content).strip()
return f'<{FORWARDED_COMMENT_TAG} author="{_attribute(author_name)}">\n{body}\n</{FORWARDED_COMMENT_TAG}>'
27 changes: 27 additions & 0 deletions products/tasks/backend/migrations/0077_taskactivity_comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.2.14 on 2026-07-31 09:29

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("posthog", "1279_drop_duckgresserverteam_table"),
("tasks", "0076_taskrun_task_run_sd_branch_idx"),
]

operations = [
migrations.AddField(
model_name="taskactivity",
name="comment",
field=models.ForeignKey(
blank=True,
db_constraint=False,
db_index=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="posthog.comment",
),
),
]
2 changes: 1 addition & 1 deletion products/tasks/backend/migrations/max_migration.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0076_taskrun_task_run_sd_branch_idx
0077_taskactivity_comment
22 changes: 19 additions & 3 deletions products/tasks/backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,20 @@ class Kind(models.TextChoices):
message = models.ForeignKey(
TaskThreadMessage, on_delete=models.SET_NULL, null=True, blank=True, related_name="activity_rows"
)
# A mention can come from a comment on one of the task's resources rather than from the
# thread. Unconstrained and reverse-less to keep this product's rows off the shared
# comments table — the feed already tolerates a row whose source has gone.
comment = models.ForeignKey(
"posthog.Comment",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
db_constraint=False,
# Unindexed on purpose: this table is upserted on every thread message, and nothing
# reads it by comment, so the only caller an index would serve is a rare hard delete.
db_index=False,
)
kind = models.CharField(max_length=32, choices=Kind)
activity_at = models.DateTimeField()
read_at = models.DateTimeField(null=True, blank=True)
Expand All @@ -1072,6 +1086,7 @@ def record(
kind: str,
activity_at: datetime,
message_id: uuid.UUID | None = None,
comment_id: uuid.UUID | None = None,
actor_id: int | None = None,
) -> None:
"""Record the latest activity on ``task_id`` for ``user_id``, newest-wins.
Expand All @@ -1089,10 +1104,11 @@ def record(
cursor.execute(
f"""
INSERT INTO {cls._meta.db_table}
(id, team_id, user_id, task_id, message_id, kind, activity_at, read_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
(id, team_id, user_id, task_id, message_id, comment_id, kind, activity_at, read_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (team_id, user_id, task_id) DO UPDATE
SET message_id = EXCLUDED.message_id,
comment_id = EXCLUDED.comment_id,
kind = EXCLUDED.kind,
activity_at = EXCLUDED.activity_at,
read_at = CASE
Expand All @@ -1102,7 +1118,7 @@ def record(
END
WHERE {cls._meta.db_table}.activity_at <= EXCLUDED.activity_at
""",
[uuid7(), team_id, user_id, task_id, message_id, kind, activity_at, read_at],
[uuid7(), team_id, user_id, task_id, message_id, comment_id, kind, activity_at, read_at],
)


Expand Down
Loading
Loading