feat(comments): notify collaborators when adding a comment - #580
Merged
Conversation
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>
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
marked this pull request as ready for review
August 18, 2026 10:39
doistbot
reviewed
Aug 18, 2026
doistbot
left a comment
Member
There was a problem hiding this comment.
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
notifyUsersbefore resolving. The array has no.max(...)bound andresolveUserRefsfans out every ref viaPromise.allbefore deduping, so duplicates or unresolvable names each triggergetUserplus 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
getProjectsand 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 inresolveRecipientsPerComment, 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.
fetchAllPagesloads every comment in the thread just for thereduceto 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)
src/mcp-server.ts:47: This bullet duplicates the
notifyUsersparameter guidance that is already fully described inadd-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 thenotifyUsers/["none"]instructions from here to avoid drift and duplicated token cost.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:needsDefaultRecipientsfetchescurrentUserwhenever any comment omitsnotifyUsers, andtargetsis built from the samecommentsarray, sotargetis always present in the default branch. Prefer throwing an invariant error here so a future refactor fails loudly instead of quietly dropping notifications.src/tools/add-comments.test.ts:526: The first half of "should surface who was notified" posts with
notifyUsers: ['none'](line 529) while theaddCommentmock returnsuidsToNotify: ['111', '222'], so thenotifiedUserIds: ['111', '222']assertion passes only becausemapCommentcopies 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']withresolveUserRefsmocked to return those two IDs — so the assertion reflects the tool's own recipient computation. The "none" → omitted-field behavior is already covered separately.
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>
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))
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #509
The problem
Comments posted through this server notify nobody.
add-commentsnever sentuidsToNotify, 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-commentsgains a per-commentnotifyUsers, accepting user IDs, emails, full names or"me"for each person — the same reference shapesresponsibleUseralready takes — resolved through the existinguserResolver. A newresolveUserRefswraps the singular resolver for lists, deduping and reporting every unresolvable reference in one error rather than failing on the first.Omitting
notifyUsersmirrors 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 throughfind-commentsandupdate-commentsfor free.@mentionsin the comment text are deliberately not parsed — the model passesnotifyUsersexplicitly, 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:
UpdateCommentArgsis{ content }only, and only the syncnote_updatecommand carries recipients.The SDK fix this needed
Live testing turned up a bug in
@doist/todoist-sdk:addCommentjoineduidsToNotifyinto a comma-separated string, which the API rejects outright with400 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:checkandnpm run buildpass.End-to-end against the live API on the published 14.0.1, using a throwaway task in a shared project (since removed):
notifyUsers: ["Ada Lovelace"]notifiedUserIds: ["9876543"], "Notified 1 person"notifyUsers: ["none"]notifyUsers: ["ada@example.com", "9876543"]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