Skip to content

feat(comments): notify collaborators when adding a comment - #580

Merged
scottlovegrove merged 3 commits into
mainfrom
feat/comment-notifications
Aug 18, 2026
Merged

feat(comments): notify collaborators when adding a comment#580
scottlovegrove merged 3 commits into
mainfrom
feat/comment-notifications

Conversation

@scottlovegrove

@scottlovegrove scottlovegrove commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Closes #509

The problem

Comments posted through this server notify nobody. add-comments never sent uidsToNotify, so a teammate named in an agent's comment found out only if they happened to open the task.

That also breaks the next comment, which is the part users notice and can't reproduce. Todoist's clients pick the recipients themselves and send them with each comment; the API notifies exactly who it is handed and derives nobody on its own. On a first comment the clients notify the assignee, the assigner and the creator; on a reply they notify the previous comment's participants, so threads keep flowing without everyone being re-tagged. A comment posted with an empty list therefore silences the comment that follows it — including one a human writes in the app. That's the behaviour @MelisUnal described on the issue (Zendesk 940230), where a scheduled agent replying in-task was "cut off right at the delivery point".

What this does

add-comments gains a per-comment notifyUsers, accepting user IDs, emails, full names or "me" for each person — the same reference shapes responsibleUser already takes — resolved through the existing userResolver. A new resolveUserRefs wraps the singular resolver for lists, deduping and reporting every unresolvable reference in one error rather than failing on the first.

Omitting notifyUsers mirrors the clients: the assignee, assigner and creator on a task's first comment, or the previous comment's participants on a reply. Passing ["none"] stays silent, following the repo's existing "remove" / "unassign" convention.

Recipients are worked out once per distinct target, so several comments on one task in a single batch read the thread once and notify the same people. Comments now also report notifiedUserIds, which lets a caller see a thread's participants before replying, and flows through find-comments and update-comments for free.

@mentions in the comment text are deliberately not parsed — the model passes notifyUsers explicitly, which avoids guessing at multi-word names, email addresses and code snippets. The server instructions tell it to do so.

Notification on edit is out of scope: UpdateCommentArgs is { content } only, and only the sync note_update command carries recipients.

The SDK fix this needed

Live testing turned up a bug in @doist/todoist-sdk: addComment joined uidsToNotify into a comma-separated string, which the API rejects outright with 400 INVALID_ARGUMENT_VALUE / uids_to_notify / "Input should be a valid list". No caller could notify anyone, and nothing caught it because no consumer set the field. Fixed in Doist/todoist-sdk-typescript#664 and released as 14.0.1, which this PR bumps to.

Verification

npx tsc --noEmit, all 1205 tests, npm run format:check and npm run build pass.

End-to-end against the live API on the published 14.0.1, using a throwaway task in a shared project (since removed):

case result
first comment, unassigned self-created task no recipients
notifyUsers: ["Ada Lovelace"] notifiedUserIds: ["9876543"], "Notified 1 person"
reply, defaults previous comment's participants, author excluded
notifyUsers: ["none"] silent, no recipient field sent
notifyUsers: ["ada@example.com", "9876543"] both refs resolve to one user, deduped to 1
two unresolvable names one error naming both

The multi-user paths — a reply picking up someone else's participants, and a first comment on a task assigned to another person — are covered by unit tests rather than live calls, since verifying them for real means sending test notifications to an actual colleague.

🤖 Generated with Claude Code

Comments posted through this server notified nobody. add-comments
never sent uidsToNotify, so a teammate named in an agent's comment
found out only if they happened to open the task.

That also broke the next comment. Todoist's clients pick the
recipients themselves and send them with each comment; the API
notifies exactly who it is handed and derives nobody on its own. A
reply takes its recipients from the comment before it, so a comment
posted with an empty list silences the following comment too --
including one a human writes in the app.

add-comments now takes notifyUsers, accepting user IDs, emails,
names or "me" for each person, resolved through the existing user
resolver. Omitting it mirrors the clients: the assignee, assigner
and creator on a task's first comment, or the previous comment's
participants on a reply. Passing ["none"] stays silent.

Recipients are worked out once per distinct target, so several
comments on one task in a single batch read the thread once and
notify the same people. Comments now also report notifiedUserIds,
which lets a caller see a thread's participants before replying.

Refs #509

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@scottlovegrove scottlovegrove self-assigned this Aug 18, 2026
Carries the fix for addComment sending uidsToNotify as a
comma-joined string, which the API rejected outright. Comment
notifications could not work without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@scottlovegrove
scottlovegrove marked this pull request as ready for review August 18, 2026 10:39
@doistbot
doistbot requested a review from nats12 August 18, 2026 10:39

@doistbot doistbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds per-comment notifyUsers to add-comments, resolving user refs through the existing userResolver and defaulting to the Todoist clients' recipient logic (assignee, assigner, creator on first comment; previous participants on replies) when the field is omitted.

Few things worth tightening:

  • Cap and dedupe notifyUsers before resolving. The array has no .max(...) bound and resolveUserRefs fans out every ref via Promise.all before deduping, so duplicates or unresolvable names each trigger getUser plus full collaborator pagination against the operator's token — a single untrusted argument can amplify into unbounded parallel SDK calls.
  • Load shared collaborator data once per list, not per ref. Because each non-ID ref independently misses the user/collaborator caches before any concurrent call populates them, a comment naming several people re-runs getProjects and every shared project's collaborator lookup once per name. Resolve the current user and collaborators up front, then match each ref against that pre-loaded set.
  • Reject or normalize notifyUsers: []. An empty array is truthy in resolveRecipientsPerComment, so it bypasses the default-recipient path and posts silently — contradicting the documented "none" opt-out and risking an accidental notification-chain break. Add .min(1) or explicitly treat [] as "use defaults."
  • Stream the latest-comment lookup instead of materializing the full thread. fetchAllPages loads every comment in the thread just for the reduce to pick one item, which can be a large allocation on long-lived tasks. Iterate the cursors and retain only the latest comment seen to keep the scan constant-memory.

I also included a few optional follow-up notes in the details below.

Optional follow-up notes (3)
  • P3 src/mcp-server.ts:47: This bullet duplicates the notifyUsers parameter guidance that is already fully described in add-comments' own input schema. The instructions block (and AGENTS.md) says to reserve this section for cross-tool routing guidance — the next bullet about add-vs-edit is that — and to keep per-tool parameter detail in the tool description. Consider keeping only the routing-relevant sentence and dropping the notifyUsers/["none"] instructions from here to avoid drift and duplicated token cost.
  • P3 src/tools/add-comments.ts:152: if (!target || !currentUser) return [] silently degrades to "notify nobody" — the exact failure this PR is meant to eliminate. Both conditions are already guaranteed by the caller: needsDefaultRecipients fetches currentUser whenever any comment omits notifyUsers, and targets is built from the same comments array, so target is always present in the default branch. Prefer throwing an invariant error here so a future refactor fails loudly instead of quietly dropping notifications.
  • P3 src/tools/add-comments.test.ts:526: The first half of "should surface who was notified" posts with notifyUsers: ['none'] (line 529) while the addComment mock returns uidsToNotify: ['111', '222'], so the notifiedUserIds: ['111', '222'] assertion passes only because mapComment copies the SDK field back. It can't fail if the tool misroutes recipients, and the ['none'] input vs. two-user output reads as a bug. Use a coherent scenario — e.g. notifyUsers: ['111', '222'] with resolveUserRefs mocked to return those two IDs — so the assertion reflects the tool's own recipient computation. The "none" → omitted-field behavior is already covered separately.

Share FeedbackReview Logs

Comment thread src/utils/user-resolver.ts Outdated
Comment thread src/tools/add-comments.ts
Comment thread src/tools/add-comments.ts
Comment thread src/utils/comment-recipients.ts Outdated
Four issues from review, all on the cost of resolving recipients:

resolveUserRefs fanned every reference out with Promise.all, so each
non-ID name missed the collaborator cache before any call had
populated it and ran its own full project-and-collaborator fetch.
References are now deduplicated and resolved one at a time, letting
the first lookup warm the cache the rest read.

notifyUsers had no upper bound, so one argument could multiply into
unbounded parallel requests against the operator's token. Capped at
ApiLimits.NOTIFY_USERS_MAX, matching the other array inputs.

An empty notifyUsers was truthy, so it skipped the default
recipients and posted silently -- an undocumented second opt-out
that cut off the notification chain. Rejected by the schema, leaving
["none"] as the only way to stay silent.

Reading a thread materialised its entire comment history to pick one
item from it. Pages are now walked keeping only the newest comment
seen. Comments come back oldest-first, so reaching the last page is
unavoidable, but holding the whole history is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@scottlovegrove scottlovegrove added the Show PR is shipped with an async review label Aug 18, 2026
@scottlovegrove
scottlovegrove merged commit 80f1308 into main Aug 18, 2026
5 checks passed
@scottlovegrove
scottlovegrove deleted the feat/comment-notifications branch August 18, 2026 10:59
doist-release-bot Bot added a commit that referenced this pull request Aug 18, 2026
## [12.6.0](v12.5.7...v12.6.0) (2026-08-18)

### Features

* **comments:** notify collaborators when adding a comment ([#580](#580)) ([80f1308](80f1308))
@doist-release-bot

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 12.6.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

scottlovegrove added a commit to Doist/todoist-cli that referenced this pull request Aug 18, 2026
Brings `td comment add` to parity with the MCP, which shipped this in
Doist/todoist-mcp#580 for Doist/todoist-mcp#509.

**Stacked on #477** — based on that branch for SDK 14.0.1, which carries
the `uidsToNotify` serialisation fix. Retarget to `main` once #477
merges.

## The problem

Comments posted with `td` notify nobody. `comment add` never sent
`uidsToNotify`, so a teammate named in a comment found out only if they
happened to open the task.

That also breaks the *next* comment, which is the part people notice and
can't reproduce. Todoist's clients pick the recipients themselves and
send them with each comment; the API notifies exactly who it is handed
and derives nobody on its own. On a first comment the clients notify the
assignee, the assigner and the creator; on a reply they notify the
previous comment's participants, so threads keep flowing without
everyone being re-tagged. A comment posted with an empty list therefore
silences the comment that follows it — including one a human later
writes in the app.

## What this does

`comment add` gains `--notify`, accepting names, emails, `id:xxx` or
`"me"`, comma-separated in the same style as `--labels`. `--no-notify`
posts in silence, matching the existing `--no-labels` negation.

Omitting `--notify` mirrors the clients: assignee, assigner and creator
on a task's first comment, or the previous comment's participants on a
reply, always excluding the author. The rules live in
`src/lib/comment-recipients.ts`, ported from the MCP's equivalent.

Two things worth calling out in review:

- **Naming yourself is honoured**, not filtered out. Self-exclusion is
only right when the recipients were *inferred* rather than asked for. I
had this filtering unconditionally at first and caught it in live
testing — `--notify me` silently did nothing.
- **The thread walk keeps only the newest comment** rather than using
`paginate()`, which accumulates every result. Comments come back
oldest-first with no reverse option, so reaching the last page is
unavoidable; holding the whole history is not. Review flagged exactly
this on the MCP version.

`resolveNotifyIds` is pure over an already-fetched collaborator list, so
the `--notify` path fetches collaborators once and reuses them to render
the names back — my own tests caught a double fetch here.

`@mentions` in the comment text are deliberately **not** parsed; the
user names people with `--notify`. `SKILL_CONTENT` documents this so
agents don't assume the text alone notifies.

Notification on *edit* is out of scope — `UpdateCommentArgs` is `{
content }` only.

## Surfacing

Who was notified is now visible in three places: the confirmation line
after adding, a `Notified:` line in `comment view` (resolved only when
there are recipients, so the common case costs no extra request), and
`postedUid` / `uidsToNotify` in plain `--json` rather than only under
`--full`.

## Verification

`npm run type-check`, `npm run check`, `npm run build`, `npm run
check:skill-sync` and all 1815 tests pass (suite run three times to rule
out flakiness).

Live against the real API on a throwaway task in a shared project, since
deleted:

| case | result |
| --- | --- |
| `--dry-run` with `--notify` | previews the raw string unresolved, no
API call |
| defaults, first comment on a solo task | no recipients |
| `--notify "Ada Lovelace"` | `Notified: Ada L.` |
| `--notify "me,ada@example.com"` | deduped to one |
| `--notify Ghost,Phantom` | one `ASSIGNEE_NOT_FOUND` naming both |
| reply, defaults | previous comment's participants, author excluded |
| `--no-notify` | silent, no recipient field sent |
| `comment view` / `--json` | recipients shown |

The paths involving a **second person** — a reply inheriting someone
else's participants, and a first comment on a task assigned to someone
else — are covered by unit tests rather than live calls, since verifying
them for real means sending test notifications to an actual colleague.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released Show PR is shipped with an async review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: Support user mentions and notifications in Todoist MCP

2 participants