diff --git a/.env.example b/.env.example index b9c229b..5fab357 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ -TWIST_API_KEY=your-key-goes-here +COMMS_API_KEY=your-key-goes-here +# Optional: override the API base URL (defaults to https://comms.todoist.com) +# COMMS_BASE_URL=https://comms.staging.todoist.com diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b789091..02e8362 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,10 +67,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Capture previous tag - id: previous_tag - run: echo "tag=$(git describe --tags --abbrev=0 2>/dev/null || true)" >> "$GITHUB_OUTPUT" - - name: Release run: npx semantic-release env: @@ -79,71 +75,3 @@ jobs: GIT_AUTHOR_EMAIL: ${{ steps.bot_user.outputs.id }}+${{ steps.generate_token.outputs.app-slug }}[bot]@users.noreply.github.com GIT_COMMITTER_NAME: ${{ steps.generate_token.outputs.app-slug }}[bot] GIT_COMMITTER_EMAIL: ${{ steps.bot_user.outputs.id }}+${{ steps.generate_token.outputs.app-slug }}[bot]@users.noreply.github.com - - - name: Derive release announcement - id: announcement - env: - PREVIOUS_TAG: ${{ steps.previous_tag.outputs.tag }} - run: | - git fetch --force --tags origin - - new_tag="$(git describe --tags --abbrev=0 2>/dev/null || true)" - if [ -z "${new_tag}" ] || [ "${new_tag}" = "${PREVIOUS_TAG}" ]; then - echo "should_announce=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - package_name="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).name")" - package_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")" - release_version="${new_tag#v}" - - package_url="https://www.npmjs.com/package/${package_name}/v/${package_version}" - release_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${new_tag}" - repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" - - if [ -n "${PREVIOUS_TAG}" ]; then - changelog="$(git log --no-merges --reverse --pretty='format:- %s (%H-%h)' "${PREVIOUS_TAG}..${new_tag}" | grep -v '^- chore(release): ' || true)" - else - changelog="$(git log --no-merges --reverse --pretty='format:- %s (%H-%h)' "${new_tag}" | grep -v '^- chore(release): ' || true)" - fi - - if [ -z "${changelog}" ]; then - changelog='- No additional commits listed.' - else - changelog="$(printf '%s\n' "${changelog}" | sed -E -e 's,\(([a-f0-9]+)-([a-f0-9]+)\),([`\2`]('"${repo_url}"'/commit/\1)),g' | sed -E -e 's,\(#([0-9]+)\),([#\1]('"${repo_url}"'/pull/\1)),g')" - fi - - { - echo "should_announce=true" - echo "package_version=${package_version}" - echo "message<> "$GITHUB_OUTPUT" - - - name: Announce release in Twist - if: ${{ steps.announcement.outputs.should_announce == 'true' }} - uses: Doist/twist-post-action@74a0255b75ad93c06b9eb1009960106efe13f5ca - with: - message: ${{ steps.announcement.outputs.message }} - install_id: ${{ secrets.TWIST_RELEASE_INSTALL_ID }} - install_token: ${{ secrets.TWIST_RELEASE_INSTALL_TOKEN }} - continue-on-error: true - - - name: Trigger twist-ai-integrations update - if: ${{ steps.announcement.outputs.should_announce == 'true' }} - run: | - gh api \ - --method POST \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/Doist/twist-ai-integrations/dispatches \ - -f event_type='twist-ai-updated' \ - -f client_payload='{"version":"${{ steps.announcement.outputs.package_version }}","npm_tag":"latest"}' - env: - GH_TOKEN: ${{ secrets.DEPLOY_TOKEN }} - continue-on-error: true diff --git a/AGENTS.md b/AGENTS.md index 5982942..f4d57ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Twist AI MCP Server - Development Guidelines +# Comms MCP Server - Development Guidelines ## Adding a New Tool @@ -48,4 +48,4 @@ npx tsx scripts/run-tool.ts search-content '{"query":"project update"}' npx tsx scripts/run-tool.ts fetch-inbox '{"workspaceId":12345}' ``` -Requires `TWIST_API_KEY` in `.env` (and optionally `TWIST_BASE_URL`). +Requires `COMMS_API_KEY` in `.env` (and optionally `COMMS_BASE_URL`, e.g. `https://comms.staging.todoist.com` for staging). diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a75600..48dd2d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,285 +1,11 @@ -## [5.4.0](https://github.com/Doist/twist-ai/compare/v5.3.0...v5.4.0) (2026-05-20) - -### Features - -* add delete-object tool ([#183](https://github.com/Doist/twist-ai/issues/183)) ([084c2b4](https://github.com/Doist/twist-ai/commit/084c2b4fba439c21e6b27b3bae4abef8249a87fc)) - -## [5.3.0](https://github.com/Doist/twist-ai/compare/v5.2.0...v5.3.0) (2026-05-12) - -### Features - -* adopt twist-sdk 2.6.0 native groups + notifyAudience for replies ([#193](https://github.com/Doist/twist-ai/issues/193)) ([ee1956b](https://github.com/Doist/twist-ai/commit/ee1956bc64bc03c6d0fa5461a891021dcd3c5e02)), closes [#188](https://github.com/Doist/twist-ai/issues/188) - -## [5.2.0](https://github.com/Doist/twist-ai/compare/v5.1.0...v5.2.0) (2026-05-06) - -### Features - -* add Twist group recipients support ([#188](https://github.com/Doist/twist-ai/issues/188)) ([f094482](https://github.com/Doist/twist-ai/commit/f094482eb4e5614b19143e842014ee2d96a61f29)) - -## [5.1.0](https://github.com/Doist/twist-ai/compare/v5.0.0...v5.1.0) (2026-04-30) - -### Features - -* add get-mentions tool for fetching user mentions ([#185](https://github.com/Doist/twist-ai/issues/185)) ([f9c2f14](https://github.com/Doist/twist-ai/commit/f9c2f146fa83953869b1a07c3e00ac7c903a2fe4)) - -## [5.0.0](https://github.com/Doist/twist-ai/compare/v4.7.1...v5.0.0) (2026-04-28) - -### ⚠ BREAKING CHANGES - -* replace update-thread/update-comment with unified update-object tool (#182) - -### Features - -* replace update-thread/update-comment with unified update-object tool ([#182](https://github.com/Doist/twist-ai/issues/182)) ([82e04c0](https://github.com/Doist/twist-ai/commit/82e04c0076e55649ded85691419798e7709048ef)) - -## [4.7.1](https://github.com/Doist/twist-ai/compare/v4.7.0...v4.7.1) (2026-04-27) - -### Bug Fixes - -* **deps:** update dependency @doist/twist-sdk to v2.4.1 ([#179](https://github.com/Doist/twist-ai/issues/179)) ([26305c7](https://github.com/Doist/twist-ai/commit/26305c7c276013ba3a55f497f770d32f4afd9cb6)) - -## [4.7.0](https://github.com/Doist/twist-ai/compare/v4.6.1...v4.7.0) (2026-04-24) - -### Features - -* add update-thread and update-comment tools ([#164](https://github.com/Doist/twist-ai/issues/164)) ([d0e4541](https://github.com/Doist/twist-ai/commit/d0e4541352c1451c96072764a47cf516aa2168fa)) - -## [4.6.1](https://github.com/Doist/twist-ai/compare/v4.6.0...v4.6.1) (2026-04-20) - -### Bug Fixes - -* **deps:** update dependency dotenv to v17.4.2 ([#174](https://github.com/Doist/twist-ai/issues/174)) ([48992e5](https://github.com/Doist/twist-ai/commit/48992e55e6ca4cacbe7337519f96a5fa1d9554b6)) - -## [4.6.0](https://github.com/Doist/twist-ai/compare/v4.5.2...v4.6.0) (2026-04-16) - -### Features - -* **fetch-inbox:** add archiveFilter parameter ([#172](https://github.com/Doist/twist-ai/issues/172)) ([6d30a8e](https://github.com/Doist/twist-ai/commit/6d30a8ec50153a1ea965a813768be2c9017b62c3)) - -## [4.5.2](https://github.com/Doist/twist-ai/compare/v4.5.1...v4.5.2) (2026-04-13) - -### Bug Fixes - -* **deps:** update production dependencies ([#169](https://github.com/Doist/twist-ai/issues/169)) ([5a13118](https://github.com/Doist/twist-ai/commit/5a13118e4243024fcac451e300dd44a6166e97c6)) - -## [4.5.1](https://github.com/Doist/twist-ai/compare/v4.5.0...v4.5.1) (2026-04-08) - -### Bug Fixes - -* add prepublishOnly script for release safety ([#154](https://github.com/Doist/twist-ai/issues/154)) ([62ef2ee](https://github.com/Doist/twist-ai/commit/62ef2eedfb358f8d6a29a744988e04d9e4c8ddc3)) -* **deps:** update dependency @doist/twist-sdk to v2.2.0 ([#159](https://github.com/Doist/twist-ai/issues/159)) ([92df923](https://github.com/Doist/twist-ai/commit/92df9231c5056f57e18ba6a7542d0ae1f24e27b6)) -* remove CHANGELOG formatting step from release pipeline ([#168](https://github.com/Doist/twist-ai/issues/168)) ([da97b19](https://github.com/Doist/twist-ai/commit/da97b192d878c2d13a060cf2c763bb00ae7aa200)) -* use individual permission inputs for create-github-app-token v3 ([#157](https://github.com/Doist/twist-ai/issues/157)) ([3298d5d](https://github.com/Doist/twist-ai/commit/3298d5dcabee330e852cf6a6d148dc9b00c91ec1)) - # Changelog -## [4.5.0](https://github.com/Doist/twist-ai/compare/v4.4.0...v4.5.0) (2026-03-23) - - -### Features - -* add list-channels tool ([#146](https://github.com/Doist/twist-ai/issues/146)) ([28f87bd](https://github.com/Doist/twist-ai/commit/28f87bd48a259581014f8a2d0089b4d3c8cce18b)) - -## [4.4.0](https://github.com/Doist/twist-ai/compare/v4.3.2...v4.4.0) (2026-03-18) - - -### Features - -* add create-thread tool ([#139](https://github.com/Doist/twist-ai/issues/139)) ([6990e54](https://github.com/Doist/twist-ai/commit/6990e54c806228099e880c8cde361908669278a3)) - -## [4.3.2](https://github.com/Doist/twist-ai/compare/v4.3.1...v4.3.2) (2026-03-18) - - -### Bug Fixes - -* support 'conversation' type in search results ([#137](https://github.com/Doist/twist-ai/issues/137)) ([0d80bf1](https://github.com/Doist/twist-ai/commit/0d80bf1b851dfb8cb0e08e8af9556b5c9bb05b72)) - -## [4.3.1](https://github.com/Doist/twist-ai/compare/v4.3.0...v4.3.1) (2026-03-13) - - -### Bug Fixes - -* use correct clear payload for away status and fix output schema ([#131](https://github.com/Doist/twist-ai/issues/131)) ([7237139](https://github.com/Doist/twist-ai/commit/7237139c8c547b7d30e62709a5f225a6e53eee86)) - -## [4.3.0](https://github.com/Doist/twist-ai/compare/v4.2.3...v4.3.0) (2026-03-13) - - -### Features - -* add `away` tool for managing user away status ([#130](https://github.com/Doist/twist-ai/issues/130)) ([504c575](https://github.com/Doist/twist-ai/commit/504c575574bdc15bef76d8773a067bfaf9409d13)) - - -### Bug Fixes - -* **deps:** update dependency dotenv to v17.3.1 ([#117](https://github.com/Doist/twist-ai/issues/117)) ([9931448](https://github.com/Doist/twist-ai/commit/99314485a275ed0173d14f557332afded8676979)) -* use PAT for release-please to trigger CI on its PRs ([#119](https://github.com/Doist/twist-ai/issues/119)) ([0168650](https://github.com/Doist/twist-ai/commit/01686500a9f892c4e3c0eb3c138eb635d3827691)) - -## [4.2.3](https://github.com/Doist/twist-ai/compare/v4.2.2...v4.2.3) (2026-02-18) - - -### Bug Fixes - -* add fallback URL construction when SDK batch validation strips .url ([#113](https://github.com/Doist/twist-ai/issues/113)) ([854b5db](https://github.com/Doist/twist-ai/commit/854b5db38eb0b1f56ea7be7517e80ce227ed001d)) -* **deps:** update dependency dotenv to v17.2.4 ([#111](https://github.com/Doist/twist-ai/issues/111)) ([de5b1cf](https://github.com/Doist/twist-ai/commit/de5b1cff3ef208ebfacdc46873eddfbef60c2a08)) - -## [4.2.2](https://github.com/Doist/twist-ai/compare/v4.2.1...v4.2.2) (2026-02-10) - - -### Bug Fixes - -* add CI workflow to satisfy required check on release PRs ([#106](https://github.com/Doist/twist-ai/issues/106)) ([92eaa10](https://github.com/Doist/twist-ai/commit/92eaa107f1b758d81015af233ba0eb68eee8d09e)) -* suppress dotenv@17 stdout logging that breaks stdio MCP transport ([#104](https://github.com/Doist/twist-ai/issues/104)) ([e2c02d0](https://github.com/Doist/twist-ai/commit/e2c02d0fffaea412014feb676aa4e2eb27c8c6f9)) - -## [4.2.1](https://github.com/Doist/twist-ai/compare/v4.2.0...v4.2.1) (2026-02-09) - - -### Bug Fixes - -* bump @doist/twist-sdk to 2.0.1 to fix missing url on batched list results ([#102](https://github.com/Doist/twist-ai/issues/102)) ([d5f8256](https://github.com/Doist/twist-ai/commit/d5f8256ea0db6a1486719272281660dfe3b348c1)) - -## [4.2.0](https://github.com/Doist/twist-ai/compare/v4.1.0...v4.2.0) (2026-02-09) - - -### Features - -* add automated cross-repository trigger for twist-ai-integrations ([#93](https://github.com/Doist/twist-ai/issues/93)) ([46e47fb](https://github.com/Doist/twist-ai/commit/46e47fb24485b69158ae9b8c251a5262e24d3f71)) -* upgrade @doist/twist-sdk to v2.0.0 and use entity.url ([#99](https://github.com/Doist/twist-ai/issues/99)) ([2e19452](https://github.com/Doist/twist-ai/commit/2e1945244af505c0c7b0f94313e10cd72a0664f6)) - -## [4.1.0](https://github.com/Doist/twist-ai/compare/v4.0.0...v4.1.0) (2026-02-02) - - -### Features - -* include Twist URLs in get-workspaces tool output ([#91](https://github.com/Doist/twist-ai/issues/91)) ([eb0d3ea](https://github.com/Doist/twist-ai/commit/eb0d3eae736d72f31161109287968c4d45c2cefa)) - -## [4.0.0](https://github.com/Doist/twist-ai/compare/v3.1.0...v4.0.0) (2026-01-27) - - -### ⚠ BREAKING CHANGES - -* **mcp:** Tool names changed (e.g. load_thread -> load-thread, fetch_inbox -> fetch-inbox, mark_done -> mark-done). - -### Features - -* **mcp:** switch tool names to kebab-case ([06f4404](https://github.com/Doist/twist-ai/commit/06f4404dcf90168d89f91e3abdee499ad0462df5)), closes [#82](https://github.com/Doist/twist-ai/issues/82) - -## [3.1.0](https://github.com/Doist/twist-ai/compare/v3.0.1...v3.1.0) (2026-01-27) - - -### Features - -* **mcp:** add ToolAnnotations hints ([fc8eaa4](https://github.com/Doist/twist-ai/commit/fc8eaa4d019ea996edac31259c4ff8b48335e4cb)) - -## [3.0.1](https://github.com/Doist/twist-ai/compare/v3.0.0...v3.0.1) (2026-01-27) - - -### Bug Fixes - -* register build_link tool in MCP server ([#83](https://github.com/Doist/twist-ai/issues/83)) ([d4ed5b4](https://github.com/Doist/twist-ai/commit/d4ed5b4cf094b8d9418648619c6fed93f8524e38)) - -## [3.0.0](https://github.com/Doist/twist-ai/compare/v2.0.0...v3.0.0) (2025-12-30) - - -### ⚠ BREAKING CHANGES - -* @modelcontextprotocol/sdk moved to peerDependencies. Consumers must now explicitly install: npm install @modelcontextprotocol/sdk@^1.25.0 - -### Features - -* make MCP SDK a peer dependency ([50eeba2](https://github.com/Doist/twist-ai/commit/50eeba20736bb8e6204ac1c25d2e43550a946443)) - - -### Bug Fixes - -* **deps:** update dependency @modelcontextprotocol/sdk to v1.25.1 ([#66](https://github.com/Doist/twist-ai/issues/66)) ([ae4f9c8](https://github.com/Doist/twist-ai/commit/ae4f9c82d85b7591e38fdcdd44bffec9e4233dc8)) - -## [2.0.0](https://github.com/Doist/twist-ai/compare/v1.2.2...v2.0.0) (2025-12-16) - - -### ⚠ BREAKING CHANGES - -* upgrade to Zod v4 ([#59](https://github.com/Doist/twist-ai/issues/59)) - -### Features - -* upgrade to Zod v4 ([#59](https://github.com/Doist/twist-ai/issues/59)) ([1ce1254](https://github.com/Doist/twist-ai/commit/1ce1254df67d56207426b0cb541939e089a5301c)) - - -### Bug Fixes - -* **deps:** update dependency @modelcontextprotocol/sdk to v1.24.0 [security] ([#57](https://github.com/Doist/twist-ai/issues/57)) ([21023c5](https://github.com/Doist/twist-ai/commit/21023c59a5d9fa94defcbd8cb661df0febbc0a07)) -* **deps:** update dependency @modelcontextprotocol/sdk to v1.24.1 ([#61](https://github.com/Doist/twist-ai/issues/61)) ([b5e52a2](https://github.com/Doist/twist-ai/commit/b5e52a2d37207a76b0720915a7772041d17ac1c1)) -* **deps:** update dependency @modelcontextprotocol/sdk to v1.24.3 ([#63](https://github.com/Doist/twist-ai/issues/63)) ([cb0e8e9](https://github.com/Doist/twist-ai/commit/cb0e8e92779e65c400b7593818af29c8ad00c221)) - -## [1.2.2](https://github.com/Doist/twist-ai/compare/v1.2.1...v1.2.2) (2025-12-02) - - -### Bug Fixes - -* always include structuredContent in tool outputs to prevent MCP validation errors ([#55](https://github.com/Doist/twist-ai/issues/55)) ([40fc577](https://github.com/Doist/twist-ai/commit/40fc5776196b443add18a746e49405349eb57980)) -* **deps:** update dependency @modelcontextprotocol/sdk to v1.23.0 ([#52](https://github.com/Doist/twist-ai/issues/52)) ([16f7d46](https://github.com/Doist/twist-ai/commit/16f7d4606b6bb8d98a3993ab52bef5cb2fead30d)) - -## [1.2.1](https://github.com/Doist/twist-ai/compare/v1.2.0...v1.2.1) (2025-11-27) - - -### Bug Fixes - -* **deps:** update production dependencies ([#47](https://github.com/Doist/twist-ai/issues/47)) ([0c4e020](https://github.com/Doist/twist-ai/commit/0c4e020d2f2ea22519055ad2534dfd70aa84a784)) - -## [1.2.0](https://github.com/Doist/twist-ai/compare/v1.1.1...v1.2.0) (2025-11-20) - - -### Features - -* add read-only and destructive hints to all tools ([#44](https://github.com/Doist/twist-ai/issues/44)) ([234f3de](https://github.com/Doist/twist-ai/commit/234f3de1dab0d5eb7b8fb6284aec1b7f36d8f00e)) - -## [1.1.1](https://github.com/Doist/twist-ai/compare/v1.1.0...v1.1.1) (2025-11-19) - - -### Bug Fixes - -* Correct repository URL case for npm provenance ([3af8429](https://github.com/Doist/twist-ai/commit/3af8429b4575c1b64c69dc9fee1a92eb58ca498a)) -* **deps:** update dependency @modelcontextprotocol/sdk to v1.21.1 ([#40](https://github.com/Doist/twist-ai/issues/40)) ([be0a8b3](https://github.com/Doist/twist-ai/commit/be0a8b38e51608a0b375495369e4ec5f248641f3)) - -## [1.1.0](https://github.com/Doist/twist-ai/compare/v1.0.0...v1.1.0) (2025-11-12) - - -### Features - -* Switch publishing from GitHub Packages to npmjs ([fda0f3b](https://github.com/Doist/twist-ai/commit/fda0f3bb6c683a0251535da5771d556cd865041e)) - - -### Bug Fixes - -* **deps:** pin dependencies ([#35](https://github.com/Doist/twist-ai/issues/35)) ([af53226](https://github.com/Doist/twist-ai/commit/af53226503cfd5b7b5209117211fd7cb941ed044)) - -## [1.0.0](https://github.com/Doist/twist-ai/compare/v0.2.2...v1.0.0) (2025-11-08) - - -### ⚠ BREAKING CHANGES - -* Update to v1 of Twist SDK ([#30](https://github.com/Doist/twist-ai/issues/30)) - -### Features - -* Add output schema support to all tools ([#28](https://github.com/Doist/twist-ai/issues/28)) ([355a341](https://github.com/Doist/twist-ai/commit/355a3414ccdd35b47e2b81d007fba71e651950ff)) -* Update to v1 of Twist SDK ([#30](https://github.com/Doist/twist-ai/issues/30)) ([a2d7110](https://github.com/Doist/twist-ai/commit/a2d7110b898e3b621f7fcadbeaf2ca164833ff38)) - -## [0.2.2-alpha.2](https://github.com/Doist/twist-ai/compare/v0.2.1-alpha.2...v0.2.2-alpha.2) (2025-10-28) - -### Bug Fixes - -- Migrate to twist-sdk v0.1.0-alpha.4 API - -## [0.2.1-alpha.2](https://github.com/Doist/twist-ai/compare/v0.2.0-alpha.2...v0.2.1-alpha.2) (2025-10-26) - -### Bug Fixes - -- Add --repo parameter to workflow run command ([a1dce5e](https://github.com/Doist/twist-ai/commit/a1dce5e44978869af7ebd7856327d2981fd85e0f)) - -## [0.2.0-alpha.2](https://github.com/Doist/twist-ai/compare/v0.1.0-alpha.2...v0.2.0-alpha.2) (2025-10-26) +All notable changes to this project will be documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -### Features +## Unreleased -- Adds `get-workspaces` and `get-users` tools ([#13](https://github.com/Doist/twist-ai/issues/13)) ([2c86c85](https://github.com/Doist/twist-ai/commit/2c86c85ff9f216608624c5baf915a95ee0d5211e)) -- Set up release-please automation with workflow_dispatch trigger ([#15](https://github.com/Doist/twist-ai/issues/15)) ([9216665](https://github.com/Doist/twist-ai/commit/9216665d926b6376a1eb165686af05800cfcaf4f)) +- Forked from [Doist/twist-ai](https://github.com/Doist/twist-ai); renamed + to `@doist/comms-mcp` and re-targeted at the Comms API via + [`@doist/comms-sdk`](https://github.com/Doist/comms-sdk-typescript). diff --git a/README.md b/README.md index 85d0e41..492eb8c 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,28 @@ -# Twist AI and MCP SDK +# Comms MCP & AI Tools -Library for connecting AI agents to Twist. Includes tools that can be integrated into LLMs, -enabling them to access and interact with a Twist workspace on the user's behalf. +MCP server and importable AI tools for the Doist Comms API. Use the +tools through an MCP server, or import them directly to plug Comms into +your own AI conversational interface. -These tools can be used both through an MCP server, or imported directly in other projects to -integrate them to your own AI conversational interfaces. +## Using the tools -## Using tools - -### 1. Add this repository as a dependency +### 1. Install ```sh -npm install @doist/twist-ai +npm install @doist/comms-mcp ``` -### 2. Import the tools and plug them to an AI +### 2. Plug them into an AI -Here's an example using [Vercel's AI SDK](https://ai-sdk.dev/docs/ai-sdk-core/generating-text#streamtext). +Example with [Vercel's AI SDK](https://ai-sdk.dev/docs/ai-sdk-core/generating-text#streamtext): ```js -import { fetchInbox, reply, markDone } from '@doist/twist-ai' +import { fetchInbox, reply, markDone } from '@doist/comms-mcp' import { streamText } from 'ai' const result = streamText({ model: yourModel, - system: 'You are a helpful Twist assistant', + system: 'You are a helpful Comms assistant', tools: { fetchInbox, reply, @@ -35,28 +33,26 @@ const result = streamText({ ## Using as an MCP server -### Quick Start - -You can run the MCP server directly with npx: +### Quick start ```bash -npx @doist/twist-ai +npx @doist/comms-mcp ``` -### Setup Guide +### Setup #### Claude Desktop -Add to your Claude Desktop configuration file (`claude_desktop_config.json`): +Add to `claude_desktop_config.json`: ```json { "mcpServers": { - "twist": { + "comms": { "command": "npx", - "args": ["-y", "@doist/twist-ai"], + "args": ["-y", "@doist/comms-mcp"], "env": { - "TWIST_API_KEY": "your-twist-api-key-here" + "COMMS_API_KEY": "your-comms-api-key-here" } } } @@ -65,151 +61,134 @@ Add to your Claude Desktop configuration file (`claude_desktop_config.json`): #### Cursor -Create a configuration file: - -- **Global:** `~/.cursor/mcp.json` -- **Project-specific:** `.cursor/mcp.json` +`~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project): ```json { "mcpServers": { - "twist": { + "comms": { "command": "npx", - "args": ["-y", "@doist/twist-ai"], + "args": ["-y", "@doist/comms-mcp"], "env": { - "TWIST_API_KEY": "your-twist-api-key-here" + "COMMS_API_KEY": "your-comms-api-key-here" } } } } ``` -Then enable the server in Cursor settings if prompted. - #### Claude Code (CLI) ```bash -claude mcp add twist npx @doist/twist-ai -``` - -Then set your API key: - -```bash -export TWIST_API_KEY=your-twist-api-key-here +claude mcp add comms npx @doist/comms-mcp +export COMMS_API_KEY=your-comms-api-key-here ``` #### Visual Studio Code -1. Open Command Palette → MCP: Add Server -2. Configure the server: +1. Command Palette → MCP: Add Server +2. Configure: ```json { "servers": { - "twist": { + "comms": { "command": "npx", - "args": ["-y", "@doist/twist-ai"], + "args": ["-y", "@doist/comms-mcp"], "env": { - "TWIST_API_KEY": "your-twist-api-key-here" + "COMMS_API_KEY": "your-comms-api-key-here" } } } } ``` -### Getting your Twist API Key +### Targeting a non-production deployment -1. Visit [https://twist.com/app_console](https://twist.com/app_console) -2. Create a new integration or use an existing one -3. Copy your API key -4. Add it to your MCP configuration as shown above +By default the server talks to `https://comms.todoist.com`. To point at +staging or a custom deployment, also set `COMMS_BASE_URL`: -## Features +```json +"env": { + "COMMS_API_KEY": "your-comms-api-key-here", + "COMMS_BASE_URL": "https://comms.staging.todoist.com" +} +``` + +### Getting a Comms API key -A key feature of this project is that tools can be reused, and are not written specifically for use in an MCP server. They can be hooked up as tools to other conversational AI interfaces (e.g. Vercel's AI SDK). +Generate a personal API token from the Comms app console, then export +it as `COMMS_API_KEY` (or paste it into the MCP client config above). -This project is in its early stages. Expect more and/or better tools soon. +## Features -Nevertheless, our goal is to provide a small set of tools that enable complete workflows, rather than just atomic actions, striking a balance between flexibility and efficiency for LLMs. +The tools are intentionally workflow-shaped rather than 1:1 wrappers +around API endpoints, so an LLM can complete a useful action with a +small number of calls. -### Available Tools +### Available tools -- **userInfo** - Get information about the current user and their workspaces -- **fetchInbox** - Fetch threads and conversations from the inbox -- **loadThread** - Load a specific thread with its comments -- **loadConversation** - Load a specific conversation with its messages -- **searchContent** - Search across a workspace for threads, comments, and messages -- **reply** - Reply to threads or conversations -- **react** - Add reactions to threads, comments, conversations, or messages -- **markDone** - Mark threads or conversations as done (read and/or archived) -- **buildLink** - Build URLs to Twist resources +- **userInfo** — Information about the current user and their workspaces +- **fetchInbox** — Threads and conversations from the inbox +- **loadThread** — Load a thread with its comments +- **loadConversation** — Load a conversation with its messages +- **searchContent** — Search a workspace for threads, comments, and messages +- **getMentions** — Threads, comments, and messages mentioning the current user +- **createThread** — Start a new channel thread +- **updateObject** / **deleteObject** — Edit or remove a thread, comment, or message +- **reply** — Reply to a thread or conversation +- **react** — Add a reaction to a thread, comment, conversation, or message +- **markDone** — Mark threads or conversations as read and/or archived +- **buildLink** — Build URLs to Comms resources +- **listChannels** / **getGroups** / **getUsers** / **getWorkspaces** — Discovery helpers -For more details on each tool, see the [src/tools](src/tools) directory. +For details, see [src/tools](src/tools). ## Dependencies -- MCP server using the official [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#installation) -- Twist TypeScript SDK [@doist/twist-sdk](https://github.com/Doist/twist-sdk-typescript) +- MCP server uses the official [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) +- Comms TypeScript SDK [@doist/comms-sdk](https://github.com/Doist/comms-sdk-typescript) -## Local Development Setup +## Local development ### Prerequisites -- Node.js 18 or higher +- Node.js 18+ - npm -- A Twist account with API access +- A Comms API token ### Setup -1. Clone the repository: - -```bash -git clone https://github.com/doist/twist-ai.git -cd twist-ai -``` - -2. Install dependencies: - ```bash +git clone https://github.com/Doist/comms-mcp.git +cd comms-mcp npm install +cp .env.example .env # then add your COMMS_API_KEY +npm run build ``` -3. Create a `.env` file with your Twist API key: +### Commands -```bash -TWIST_API_KEY=your-twist-api-key-here -``` +- `npm start` — Build and run the MCP inspector +- `npm run dev` — Watch mode with auto-restart +- `npm test` — Jest +- `npm run type-check` — TypeScript +- `npm run format:check` / `npm run format:fix` — oxlint + oxfmt -4. Build the project: +### Running a single tool directly ```bash -npm run build +npx tsx scripts/run-tool.ts user-info '{}' +npx tsx scripts/run-tool.ts --list ``` -### Development Commands - -- `npm start` - Build and run the MCP inspector for testing -- `npm run dev` - Development mode with auto-rebuild and restart -- `npm test` - Run all tests -- `npm run type-check` - Run TypeScript type checking -- `npm run format:check` - Run linting and formatting checks -- `npm run format:fix` - Auto-fix linting and formatting issues - ## Contributing -Contributions are welcome! Please ensure: - -1. All tests pass (`npm test`) -2. Code is properly typed (`npm run type-check`) -3. Code passes linting and formatting checks (`npm run format:check`) - -Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages: +1. Tests pass (`npm test`) +2. Types pass (`npm run type-check`) +3. Lint & format pass (`npm run format:check`) -- `feat:` for new features -- `fix:` for bug fixes -- `docs:` for documentation changes -- `test:` for test changes -- `chore:` for maintenance tasks +Use [Conventional Commits](https://www.conventionalcommits.org/) — `feat:`, `fix:`, `docs:`, `test:`, `chore:`. ## License diff --git a/jest.config.js b/jest.config.js index 3a1a3f8..58e4122 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,7 +29,7 @@ export default { }, ], }, - transformIgnorePatterns: ['node_modules/(?!(@doist/twist-sdk|camelcase)/)'], + transformIgnorePatterns: ['node_modules/(?!(@doist/comms-sdk|camelcase|p-limit|yocto-queue)/)'], collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', diff --git a/package-lock.json b/package-lock.json index c4b617a..767cca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,21 @@ { - "name": "@doist/twist-ai", - "version": "5.4.0", + "name": "@doist/comms-mcp", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@doist/twist-ai", - "version": "5.4.0", + "name": "@doist/comms-mcp", + "version": "0.1.0", "license": "MIT", "dependencies": { - "@doist/twist-sdk": "2.6.0", + "@doist/comms-sdk": "^0.2.0", "dotenv": "17.4.2", + "p-limit": "6.2.0", "zod": "4.1.13" }, "bin": { - "twist-ai": "dist/main.js" + "comms-mcp": "dist/main.js" }, "devDependencies": { "@semantic-release/changelog": "6.0.3", @@ -592,10 +593,10 @@ "node": ">=0.1.90" } }, - "node_modules/@doist/twist-sdk": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@doist/twist-sdk/-/twist-sdk-2.6.0.tgz", - "integrity": "sha512-KLfE38gNPrb4LQfaGlyoX0WrdFCYHnnUgvj7VaIo0PlJKCL9hG+r5GhTa+kWH7Vlnq8Kr3be3zWqfCCi97lZ6Q==", + "node_modules/@doist/comms-sdk": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-0.2.0.tgz", + "integrity": "sha512-ywZEls1fYvZtKmfADvxXD0kAtw+u5P75+TraV9VkAjA509AG6Q69ldkaHo5s9SQX6YCyQAZkuv8KaGDDrRJTrw==", "license": "MIT", "dependencies": { "camelcase": "8.0.0", @@ -608,7 +609,7 @@ "type-fest": "^4.12.0 || ^5.1.0" } }, - "node_modules/@doist/twist-sdk/node_modules/zod": { + "node_modules/@doist/comms-sdk/node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", @@ -6075,6 +6076,35 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-changed-files/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-changed-files/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-circus": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", @@ -6107,6 +6137,35 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-circus/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-circus/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-cli": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", @@ -6443,6 +6502,35 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-runner/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-runner/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-runtime": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", @@ -9527,16 +9615,15 @@ } }, "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "yocto-queue": "^1.1.1" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12439,13 +12526,12 @@ } }, "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index e431f9e..f0b5889 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "@doist/twist-ai", - "version": "5.4.0", + "name": "@doist/comms-mcp", + "version": "0.1.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", - "mcpName": "net.twist/mcp", + "mcpName": "com.doist/comms-mcp", "bin": { - "twist-ai": "dist/main.js" + "comms-mcp": "dist/main.js" }, "files": [ "dist", @@ -15,17 +15,17 @@ "README.md" ], "license": "MIT", - "description": "A collection of tools for Twist using AI", + "description": "MCP server and importable AI tools for the Doist Comms API", "author": "Doist", "repository": { "type": "git", - "url": "git+https://github.com/Doist/twist-ai.git" + "url": "git+https://github.com/Doist/comms-mcp.git" }, "keywords": [ - "twist", + "comms", + "doist", "ai", "mcp", - "doist", "collaboration", "model-context-protocol" ], @@ -45,8 +45,9 @@ "prepublishOnly": "npm run build && npm test" }, "dependencies": { - "@doist/twist-sdk": "2.6.0", + "@doist/comms-sdk": "^0.2.0", "dotenv": "17.4.2", + "p-limit": "6.2.0", "zod": "4.1.13" }, "peerDependencies": { diff --git a/scripts/run-tool.ts b/scripts/run-tool.ts index 5886172..fb902a0 100644 --- a/scripts/run-tool.ts +++ b/scripts/run-tool.ts @@ -1,6 +1,6 @@ #!/usr/bin/env npx tsx /** - * Run any Twist tool directly without going through MCP. + * Run any Comms tool directly without going through MCP. * * Usage: * npx tsx scripts/run-tool.ts '' @@ -12,12 +12,11 @@ * npx tsx scripts/run-tool.ts search-content '{"query":"project update"}' * npx tsx scripts/run-tool.ts fetch-inbox '{"workspaceId":12345}' * - * Requires TWIST_API_KEY in .env file (and optionally TWIST_BASE_URL). + * Requires COMMS_API_KEY in .env file (and optionally COMMS_BASE_URL). */ import { readFileSync } from 'node:fs' -import { TwistApi } from '@doist/twist-sdk' +import { CommsApi } from '@doist/comms-sdk' import { config } from 'dotenv' -import { away } from '../src/tools/away.js' import { buildLink } from '../src/tools/build-link.js' import { createThread } from '../src/tools/create-thread.js' import { deleteObject } from '../src/tools/delete-object.js' @@ -43,7 +42,7 @@ type ExecutableTool = { execute: ( // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- tools have varying parameter schemas args: any, - client: TwistApi, + client: CommsApi, ) => Promise<{ content?: Array<{ type: string; text: string }> structuredContent?: unknown @@ -67,7 +66,6 @@ const tools: Record = { 'get-workspaces': getWorkspaces, 'get-users': getUsers, 'get-groups': getGroups, - away: away, 'list-channels': listChannels, } @@ -139,14 +137,14 @@ async function main() { process.exit(1) } - const apiKey = process.env.TWIST_API_KEY + const apiKey = process.env.COMMS_API_KEY if (!apiKey) { - console.error('TWIST_API_KEY not found in environment or .env file') + console.error('COMMS_API_KEY not found in environment or .env file') process.exit(1) } - const baseUrl = process.env.TWIST_BASE_URL - const client = new TwistApi(apiKey, { baseUrl }) + const baseUrl = process.env.COMMS_BASE_URL + const client = new CommsApi(apiKey, { baseUrl }) console.log(`Running ${toolName} with args:`) console.log(JSON.stringify(parsedArgs, null, 2)) diff --git a/src/twist-tool.ts b/src/comms-tool.ts similarity index 79% rename from src/twist-tool.ts rename to src/comms-tool.ts index d7116ec..6ef8a57 100644 --- a/src/twist-tool.ts +++ b/src/comms-tool.ts @@ -1,11 +1,11 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import type { z } from 'zod' import type { RequiredToolAnnotations } from './utils/required-tool-annotations.js' /** - * A Twist tool that can be used in an MCP server or other conversational AI interfaces. + * A Comms tool that can be used in an MCP server or other conversational AI interfaces. */ -type TwistTool = { +type CommsTool = { /** * The name of the tool. */ @@ -46,10 +46,10 @@ type TwistTool>, client: TwistApi) => Promise + execute: (args: z.infer>, client: CommsApi) => Promise } -export type { TwistTool } +export type { CommsTool } diff --git a/src/index.ts b/src/index.ts index 87b074f..957818e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ import { getMcpServer } from './mcp-server.js' -import { away } from './tools/away.js' import { buildLink } from './tools/build-link.js' import { createThread } from './tools/create-thread.js' import { deleteObject } from './tools/delete-object.js' @@ -17,7 +16,6 @@ import { updateObject } from './tools/update-object.js' import { userInfo } from './tools/user-info.js' const tools = { - away, userInfo, fetchInbox, loadThread, @@ -38,7 +36,6 @@ const tools = { export { tools, getMcpServer } export { - away, userInfo, fetchInbox, loadThread, diff --git a/src/main.ts b/src/main.ts index b848663..3ad5426 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,12 +4,12 @@ import dotenv from 'dotenv' import { getMcpServer } from './mcp-server.js' function main() { - const twistApiKey = process.env.TWIST_API_KEY - if (!twistApiKey) { - throw new Error('TWIST_API_KEY is not set') + const commsApiKey = process.env.COMMS_API_KEY + if (!commsApiKey) { + throw new Error('COMMS_API_KEY is not set') } - const server = getMcpServer({ twistApiKey }) + const server = getMcpServer({ commsApiKey }) const transport = new StdioServerTransport() server .connect(transport) @@ -18,7 +18,7 @@ function main() { console.error('Server started') }) .catch((error) => { - console.error('Error starting the Twist MCP server:', error) + console.error('Error starting the Comms MCP server:', error) process.exit(1) }) } diff --git a/src/mcp-helpers.ts b/src/mcp-helpers.ts index 5ca8b86..34b3a9b 100644 --- a/src/mcp-helpers.ts +++ b/src/mcp-helpers.ts @@ -1,8 +1,8 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js' import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' -import type { TwistTool } from './twist-tool.js' +import type { CommsTool } from './comms-tool.js' import { formatToolTitle } from './utils/required-tool-annotations.js' import { removeNullFields } from './utils/sanitize-data.js' @@ -81,15 +81,15 @@ function getMcpAnnotations(tool: { name: string; annotations: ToolAnnotations }) } /** - * Register a Twist tool in an MCP server. + * Register a Comms tool in an MCP server. * @param tool - The tool to register. * @param server - The server to register the tool on. - * @param client - The Twist API client to use to execute the tool. + * @param client - The Comms API client to use to execute the tool. */ function registerTool( - tool: TwistTool, + tool: CommsTool, server: McpServer, - client: TwistApi, + client: CommsApi, ) { // @ts-expect-error I give up const cb: ToolCallback = async (args: z.infer>, _context) => { diff --git a/src/mcp-server.ts b/src/mcp-server.ts index f375813..35031c5 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,7 +1,6 @@ -import { TwistApi } from '@doist/twist-sdk' +import { CommsApi } from '@doist/comms-sdk' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { registerTool } from './mcp-helpers.js' -import { away } from './tools/away.js' import { buildLink } from './tools/build-link.js' import { createThread } from './tools/create-thread.js' import { deleteObject } from './tools/delete-object.js' @@ -21,9 +20,9 @@ import { updateObject } from './tools/update-object.js' import { userInfo } from './tools/user-info.js' const instructions = ` -## Twist Communication Tools +## Comms Communication Tools -You have access to comprehensive Twist management tools for team communication and collaboration. Use these tools to help users manage threads, messages, channels, and team interactions effectively. +You have access to comprehensive Comms management tools for team communication and collaboration. Use these tools to help users manage threads, messages, channels, and team interactions effectively. ### Core Capabilities: - Create and manage conversations and threads @@ -40,7 +39,7 @@ You have access to comprehensive Twist management tools for team communication a - **reply**: Use to reply to a thread or conversation. Thread replies notify everyone who has interacted with the thread by default. Optionally pass recipients for user IDs or groups for group IDs to override that default, and/or notifyAudience ("channel" | "thread") to add a broader audience on top of recipients/groups. Passing groups or notifyAudience to a conversation reply is rejected. - **get-mentions**: Use to fetch threads, comments, and messages that mention the current user. Prefer this over search-content when no keyword query is needed (search-content requires a non-empty query). Supports filtering by channel, author, and date range, and exposes a cursor for pagination. - **update-object**: Use to edit something you previously sent. Pass targetType ("thread", "comment", or "message"), targetId, and the new content. For threads you may also pass title (and may pass title without content). title is only valid for threads. -- **delete-object**: Use to permanently delete a thread, comment, or conversation message. Pass targetType ("thread", "comment", or "message") and targetId. Deletion is irreversible — confirm with the user before invoking. Deleting a thread also removes all of its comments. Only the object's creator or a workspace admin can delete; the Twist API will reject the call otherwise. +- **delete-object**: Use to permanently delete a thread, comment, or conversation message. Pass targetType ("thread", "comment", or "message") and targetId. Deletion is irreversible — confirm with the user before invoking. Deleting a thread also removes all of its comments. Only the object's creator or a workspace admin can delete; the Comms API will reject the call otherwise. ### Best Practices: @@ -55,13 +54,13 @@ Always provide clear context and maintain professional communication standards. /** * Create the MCP server. - * @param twistApiKey - The API key for the Twist account. - * @param baseUrl - Optional base URL for the Twist API. + * @param commsApiKey - The API key for the Comms account. + * @param baseUrl - Optional base URL for the Comms API. * @returns the MCP server. */ -function getMcpServer({ twistApiKey, baseUrl }: { twistApiKey: string; baseUrl?: string }) { +function getMcpServer({ commsApiKey, baseUrl }: { commsApiKey: string; baseUrl?: string }) { const server = new McpServer( - { name: 'twist-mcp-server', version: '0.1.0' }, + { name: 'comms-mcp-server', version: '0.1.0' }, { capabilities: { tools: { listChanged: true }, @@ -70,27 +69,26 @@ function getMcpServer({ twistApiKey, baseUrl }: { twistApiKey: string; baseUrl?: }, ) - const twist = new TwistApi(twistApiKey, { baseUrl }) + const comms = new CommsApi(commsApiKey, { baseUrl }) // Register tools - registerTool(userInfo, server, twist) - registerTool(away, server, twist) - registerTool(getWorkspaces, server, twist) - registerTool(getUsers, server, twist) - registerTool(getGroups, server, twist) - registerTool(fetchInbox, server, twist) - registerTool(loadThread, server, twist) - registerTool(loadConversation, server, twist) - registerTool(searchContent, server, twist) - registerTool(getMentions, server, twist) - registerTool(buildLink, server, twist) - registerTool(createThread, server, twist) - registerTool(updateObject, server, twist) - registerTool(deleteObject, server, twist) - registerTool(reply, server, twist) - registerTool(react, server, twist) - registerTool(markDone, server, twist) - registerTool(listChannels, server, twist) + registerTool(userInfo, server, comms) + registerTool(getWorkspaces, server, comms) + registerTool(getUsers, server, comms) + registerTool(getGroups, server, comms) + registerTool(fetchInbox, server, comms) + registerTool(loadThread, server, comms) + registerTool(loadConversation, server, comms) + registerTool(searchContent, server, comms) + registerTool(getMentions, server, comms) + registerTool(buildLink, server, comms) + registerTool(createThread, server, comms) + registerTool(updateObject, server, comms) + registerTool(deleteObject, server, comms) + registerTool(reply, server, comms) + registerTool(react, server, comms) + registerTool(markDone, server, comms) + registerTool(listChannels, server, comms) return server } diff --git a/src/tools/__tests__/__snapshots__/create-thread.test.ts.snap b/src/tools/__tests__/__snapshots__/create-thread.test.ts.snap index f6549fa..33a9bca 100644 --- a/src/tools/__tests__/__snapshots__/create-thread.test.ts.snap +++ b/src/tools/__tests__/__snapshots__/create-thread.test.ts.snap @@ -4,10 +4,10 @@ exports[`create-thread tool creating threads should create a thread in a channel "# Thread Created **Title:** New Discussion -**Thread ID:** 12345 -**Channel ID:** 67890 +**Thread ID:** thread-id-1 +**Channel ID:** channel-id-1 **Created:** 2024-01-01T00:00:00.000Z -**URL:** https://twist.com/a/11111/ch/67890/t/12345/ +**URL:** https://comms.todoist.com/a/11111/ch/channel-id-1/t/thread-id-1/ ## Content @@ -20,10 +20,10 @@ exports[`create-thread tool creating threads should create a thread with recipie "# Thread Created **Title:** Notify Users -**Thread ID:** 12345 -**Channel ID:** 67890 +**Thread ID:** thread-id-1 +**Channel ID:** channel-id-1 **Created:** 2024-01-01T00:00:00.000Z -**URL:** https://twist.com/a/11111/ch/67890/t/12345/ +**URL:** https://comms.todoist.com/a/11111/ch/channel-id-1/t/thread-id-1/ ## Content diff --git a/src/tools/__tests__/__snapshots__/delete-object.test.ts.snap b/src/tools/__tests__/__snapshots__/delete-object.test.ts.snap index c80b75e..96c4034 100644 --- a/src/tools/__tests__/__snapshots__/delete-object.test.ts.snap +++ b/src/tools/__tests__/__snapshots__/delete-object.test.ts.snap @@ -3,7 +3,7 @@ exports[`delete-object tool targetType: comment should delete a comment by ID 1`] = ` "# Comment Deleted -**Comment ID:** 54321 +**Comment ID:** comment-id-1 The comment has been permanently deleted." `; @@ -11,7 +11,7 @@ The comment has been permanently deleted." exports[`delete-object tool targetType: message should delete a conversation message by ID 1`] = ` "# Message Deleted -**Message ID:** 98765 +**Message ID:** msg-id-1 The conversation message has been permanently deleted." `; @@ -19,7 +19,7 @@ The conversation message has been permanently deleted." exports[`delete-object tool targetType: thread should delete a thread by ID 1`] = ` "# Thread Deleted -**Thread ID:** 12345 +**Thread ID:** thread-id-1 The thread has been permanently deleted." `; diff --git a/src/tools/__tests__/__snapshots__/fetch-inbox.test.ts.snap b/src/tools/__tests__/__snapshots__/fetch-inbox.test.ts.snap index 8294520..c9fd3b6 100644 --- a/src/tools/__tests__/__snapshots__/fetch-inbox.test.ts.snap +++ b/src/tools/__tests__/__snapshots__/fetch-inbox.test.ts.snap @@ -8,8 +8,8 @@ exports[`fetch-inbox tool fetching inbox successfully should fetch inbox with th ## Threads (2) -- [Test Channel] Test Thread 1 🔵 (ID: 12345) -- [Test Channel] Test Thread 2 ⭐ (ID: 12346) +- [Test Channel] Test Thread 1 [unread] (ID: thread-id-1) +- [Test Channel] Test Thread 2 [saved] (ID: thread-id-2) ## Next Steps @@ -32,8 +32,8 @@ _No threads in inbox_ ## Conversations (2) -- DM with Alice, Bob 🔵 (ID: 33333) -- Project Discussion 🔵 (ID: 33334) +- DM with Alice, Bob [unread] (ID: conv-id-1) +- Project Discussion [unread] (ID: conv-id-2) ## Next Steps @@ -62,7 +62,7 @@ exports[`fetch-inbox tool fetching inbox successfully should filter only unread ## Threads (1) -- [Test Channel] Unread Thread 🔵 (ID: 12345) +- [Test Channel] Unread Thread [unread] (ID: thread-id-1) ## Next Steps @@ -91,7 +91,7 @@ exports[`fetch-inbox tool fetching inbox successfully should not display convers ## Threads (1) -- [Test Channel] Test Thread 🔵 (ID: 12345) +- [Test Channel] Test Thread [unread] (ID: thread-id-1) ## Next Steps diff --git a/src/tools/__tests__/__snapshots__/load-conversation.test.ts.snap b/src/tools/__tests__/__snapshots__/load-conversation.test.ts.snap index ca7f042..de35975 100644 --- a/src/tools/__tests__/__snapshots__/load-conversation.test.ts.snap +++ b/src/tools/__tests__/__snapshots__/load-conversation.test.ts.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`load-conversation tool loading conversations successfully should filter messages by date range 1`] = ` -"# Conversation 33333 +"# Conversation conv-id-1 -**Conversation ID:** 33333 +**Conversation ID:** conv-id-1 **Workspace ID:** 11111 **Archived:** No **Last Active:** 2024-01-01T00:00:00.000Z @@ -17,9 +17,9 @@ Test User 1, `; exports[`load-conversation tool loading conversations successfully should handle conversation with no messages 1`] = ` -"# Conversation 33333 +"# Conversation conv-id-1 -**Conversation ID:** 33333 +**Conversation ID:** conv-id-1 **Workspace ID:** 11111 **Archived:** No **Last Active:** 2024-01-01T00:00:00.000Z @@ -33,9 +33,9 @@ Test User 1, `; exports[`load-conversation tool loading conversations successfully should load conversation with messages and participants 1`] = ` -"# Conversation 33333 +"# Conversation conv-id-1 -**Conversation ID:** 33333 +**Conversation ID:** conv-id-1 **Workspace ID:** 11111 **Archived:** No **Last Active:** 2024-01-01T00:00:00.000Z @@ -46,12 +46,12 @@ Test User 1, Test User 2 ## Messages (2) -### Message 98765 +### Message msg-id-1 **Creator:** Test User 1 | **Posted:** 2024-01-01T00:00:00.000Z Test message content -### Message 98766 +### Message msg-id-2 **Creator:** Test User 1 | **Posted:** 2024-01-01T00:00:00.000Z Test message content @@ -59,9 +59,9 @@ Test message content `; exports[`load-conversation tool loading conversations successfully should load conversation without participants when includeParticipants is false 1`] = ` -"# Conversation 33333 +"# Conversation conv-id-1 -**Conversation ID:** 33333 +**Conversation ID:** conv-id-1 **Workspace ID:** 11111 **Archived:** No **Last Active:** 2024-01-01T00:00:00.000Z diff --git a/src/tools/__tests__/__snapshots__/load-thread.test.ts.snap b/src/tools/__tests__/__snapshots__/load-thread.test.ts.snap index 871d0a4..7fbd64c 100644 --- a/src/tools/__tests__/__snapshots__/load-thread.test.ts.snap +++ b/src/tools/__tests__/__snapshots__/load-thread.test.ts.snap @@ -3,7 +3,7 @@ exports[`load-thread tool loading threads successfully should filter comments by date 1`] = ` "# Thread: Test Thread -**Thread ID:** 12345 +**Thread ID:** thread-id-1 **Channel:** Test Channel **Workspace ID:** 11111 **Creator:** Test User 1 (22222) @@ -26,7 +26,7 @@ Test User 1 (22222)" exports[`load-thread tool loading threads successfully should handle thread with no comments 1`] = ` "# Thread: Test Thread -**Thread ID:** 12345 +**Thread ID:** thread-id-1 **Channel:** Test Channel **Workspace ID:** 11111 **Creator:** Test User 1 (22222) @@ -49,7 +49,7 @@ Test User 1 (22222)" exports[`load-thread tool loading threads successfully should load thread with comments and participants 1`] = ` "# Thread: Test Thread -**Thread ID:** 12345 +**Thread ID:** thread-id-1 **Channel:** Test Channel **Workspace ID:** 11111 **Creator:** Test User 1 (22222) @@ -64,12 +64,12 @@ Test thread content ## Comments (2) -### Comment 54321 +### Comment comment-id-1 **Creator:** Test User 1 (22222) | **Posted:** 2024-01-01T00:00:00.000Z Test comment content -### Comment 54322 +### Comment comment-id-2 **Creator:** Test User 2 (44444) | **Posted:** 2024-01-01T00:00:00.000Z Test comment content @@ -82,7 +82,7 @@ Test User 1 (22222), Test User 2 (44444)" exports[`load-thread tool loading threads successfully should load thread without participants when includeParticipants is false 1`] = ` "# Thread: Test Thread -**Thread ID:** 12345 +**Thread ID:** thread-id-1 **Channel:** Test Channel **Workspace ID:** 11111 **Creator:** Test User 1 (22222) diff --git a/src/tools/__tests__/__snapshots__/mark-done.test.ts.snap b/src/tools/__tests__/__snapshots__/mark-done.test.ts.snap index 366d43a..1cc264c 100644 --- a/src/tools/__tests__/__snapshots__/mark-done.test.ts.snap +++ b/src/tools/__tests__/__snapshots__/mark-done.test.ts.snap @@ -1,55 +1,55 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`mark-done tool bulk thread operations should archive all without marking as read in workspace 1`] = ` +exports[`mark-done tool bulk thread operations marks all read in a channel without archiving when archive=false 1`] = ` "# Mark Threads Done **Mode:** Bulk Operation -**Workspace ID:** 11111 -**Mark Read:** No -**Archive:** Yes +**Channel ID:** channel-id-1 +**Mark Read:** Yes +**Archive:** No -✅ Bulk operation completed successfully +Bulk operation completed successfully ## Next Steps Use \`fetch-inbox\` to see remaining unread threads." `; -exports[`mark-done tool bulk thread operations should clear all unread markers in a workspace 1`] = ` +exports[`mark-done tool bulk thread operations should archive all without marking as read in workspace 1`] = ` "# Mark Threads Done **Mode:** Bulk Operation **Workspace ID:** 11111 -**Operation:** Clear all unread markers +**Mark Read:** No +**Archive:** Yes -✅ Bulk operation completed successfully +Bulk operation completed successfully ## Next Steps Use \`fetch-inbox\` to see remaining unread threads." `; -exports[`mark-done tool bulk thread operations should mark all as read without archiving in workspace 1`] = ` +exports[`mark-done tool bulk thread operations should clear all unread markers in a workspace 1`] = ` "# Mark Threads Done **Mode:** Bulk Operation **Workspace ID:** 11111 -**Mark Read:** Yes -**Archive:** No +**Operation:** Clear all unread markers -✅ Bulk operation completed successfully +Bulk operation completed successfully ## Next Steps Use \`fetch-inbox\` to see remaining unread threads." `; -exports[`mark-done tool bulk thread operations should mark all threads as read and archive in a channel 1`] = ` +exports[`mark-done tool bulk thread operations should mark all as read without archiving in workspace 1`] = ` "# Mark Threads Done **Mode:** Bulk Operation -**Channel ID:** 67890 +**Workspace ID:** 11111 **Mark Read:** Yes -**Archive:** Yes +**Archive:** No -✅ Bulk operation completed successfully +Bulk operation completed successfully ## Next Steps Use \`fetch-inbox\` to see remaining unread threads." @@ -63,7 +63,7 @@ exports[`mark-done tool bulk thread operations should mark all threads as read a **Mark Read:** Yes **Archive:** Yes -✅ Bulk operation completed successfully +Bulk operation completed successfully ## Next Steps Use \`fetch-inbox\` to see remaining unread threads." @@ -74,18 +74,18 @@ exports[`mark-done tool marking conversations as done should handle conversation **Mode:** Individual IDs **Total Requested:** 1 -**Successful:** 1 -**Failed:** 0 +**Successful:** 0 +**Failed:** 1 **Mark Read:** Yes **Archive:** No -## Completed +## Failed -33333 +- conversation conv-id-1: Conversation not found ## Next Steps -Check your conversations for remaining unread messages." +Review failed items and retry if needed." `; exports[`mark-done tool marking conversations as done should mark all conversations as done successfully 1`] = ` @@ -100,7 +100,7 @@ exports[`mark-done tool marking conversations as done should mark all conversati ## Completed -33333, 33334 +conv-id-1, conv-id-2 ## Next Steps @@ -119,7 +119,7 @@ exports[`mark-done tool marking threads as done should archive thread only 1`] = ## Completed -12345 +thread-id-1 ## Next Steps @@ -138,8 +138,8 @@ exports[`mark-done tool marking threads as done should handle all threads failin ## Failed -- thread 12345: API Error: Network timeout -- thread 12346: API Error: Network timeout +- thread thread-id-1: API Error: Network timeout +- thread thread-id-2: API Error: Network timeout ## Next Steps @@ -158,11 +158,11 @@ exports[`mark-done tool marking threads as done should handle partial failures g ## Completed -12345, 12347 +thread-id-1, thread-id-3 ## Failed -- thread 12346: Thread not found +- thread thread-id-2: Thread not found ## Next Steps @@ -181,7 +181,7 @@ exports[`mark-done tool marking threads as done should mark all threads as done ## Completed -12345, 12346, 12347 +thread-id-1, thread-id-2, thread-id-3 ## Next Steps @@ -200,7 +200,7 @@ exports[`mark-done tool marking threads as done should mark thread as read only ## Completed -12345 +thread-id-1 ## Next Steps @@ -219,7 +219,7 @@ exports[`mark-done tool next steps logic validation should suggest fetch-inbox w ## Completed -12345, 12346 +thread-id-1, thread-id-2 ## Next Steps @@ -238,11 +238,11 @@ exports[`mark-done tool next steps logic validation should suggest reviewing fai ## Completed -12345 +thread-id-1 ## Failed -- thread 12346: Thread not found +- thread thread-id-2: Thread not found ## Next Steps diff --git a/src/tools/__tests__/__snapshots__/react.test.ts.snap b/src/tools/__tests__/__snapshots__/react.test.ts.snap index e358f2c..7eacb62 100644 --- a/src/tools/__tests__/__snapshots__/react.test.ts.snap +++ b/src/tools/__tests__/__snapshots__/react.test.ts.snap @@ -3,7 +3,7 @@ exports[`react tool adding reactions should add reaction to a comment 1`] = ` "# Reaction Added -**Target:** comment 54321 +**Target:** comment comment-id-1 **Emoji:** ❤️ **Operation:** add" `; @@ -11,7 +11,7 @@ exports[`react tool adding reactions should add reaction to a comment 1`] = ` exports[`react tool adding reactions should add reaction to a message 1`] = ` "# Reaction Added -**Target:** message 98765 +**Target:** message msg-id-1 **Emoji:** 🎉 **Operation:** add" `; @@ -19,7 +19,7 @@ exports[`react tool adding reactions should add reaction to a message 1`] = ` exports[`react tool adding reactions should add reaction to a thread 1`] = ` "# Reaction Added -**Target:** thread 12345 +**Target:** thread thread-id-1 **Emoji:** 👍 **Operation:** add" `; @@ -27,7 +27,7 @@ exports[`react tool adding reactions should add reaction to a thread 1`] = ` exports[`react tool removing reactions should remove reaction from a comment 1`] = ` "# Reaction Removed -**Target:** comment 54321 +**Target:** comment comment-id-1 **Emoji:** ❤️ **Operation:** remove" `; @@ -35,7 +35,7 @@ exports[`react tool removing reactions should remove reaction from a comment 1`] exports[`react tool removing reactions should remove reaction from a message 1`] = ` "# Reaction Removed -**Target:** message 98765 +**Target:** message msg-id-1 **Emoji:** 🎉 **Operation:** remove" `; @@ -43,7 +43,7 @@ exports[`react tool removing reactions should remove reaction from a message 1`] exports[`react tool removing reactions should remove reaction from a thread 1`] = ` "# Reaction Removed -**Target:** thread 12345 +**Target:** thread thread-id-1 **Emoji:** 👍 **Operation:** remove" `; diff --git a/src/tools/__tests__/__snapshots__/reply.test.ts.snap b/src/tools/__tests__/__snapshots__/reply.test.ts.snap index fdeeca0..a03a9fd 100644 --- a/src/tools/__tests__/__snapshots__/reply.test.ts.snap +++ b/src/tools/__tests__/__snapshots__/reply.test.ts.snap @@ -3,8 +3,8 @@ exports[`reply tool replying to conversations should post a message to a conversation 1`] = ` "# Reply Posted -**Target:** Conversation 33333 -**Reply ID:** 98765 +**Target:** Conversation conv-id-1 +**Reply ID:** msg-id-1 **Created:** 2024-01-01T00:00:00.000Z ## Content @@ -15,8 +15,8 @@ This is my message" exports[`reply tool replying to threads should post a comment to a thread 1`] = ` "# Reply Posted -**Target:** Thread 12345 -**Reply ID:** 54321 +**Target:** Thread thread-id-1 +**Reply ID:** comment-id-1 **Created:** 2024-01-01T00:00:00.000Z ## Content @@ -27,8 +27,8 @@ This is my reply" exports[`reply tool replying to threads should post a comment with recipients 1`] = ` "# Reply Posted -**Target:** Thread 12345 -**Reply ID:** 54321 +**Target:** Thread thread-id-1 +**Reply ID:** comment-id-1 **Created:** 2024-01-01T00:00:00.000Z ## Content diff --git a/src/tools/__tests__/__snapshots__/search-content.test.ts.snap b/src/tools/__tests__/__snapshots__/search-content.test.ts.snap index e36bbb3..cf70b25 100644 --- a/src/tools/__tests__/__snapshots__/search-content.test.ts.snap +++ b/src/tools/__tests__/__snapshots__/search-content.test.ts.snap @@ -21,14 +21,14 @@ exports[`search-content tool workspace search should search across workspace wit ### Thread thread-123 **Created:** 2024-01-01 | **Creator:** Test User 1 (22222) -**Thread:** 12345 -**Channel:** Test Channel (67890) +**Thread:** thread-id-1 +**Channel:** Test Channel (channel-id-1) Test thread matching query ### Comment comment-456 **Created:** 2024-01-01 | **Creator:** Test User 1 (22222) -**Thread:** 12345 +**Thread:** thread-id-1 Test comment matching query " @@ -45,8 +45,8 @@ exports[`search-content tool workspace search should search with filters 1`] = ` ### Thread thread-789 **Created:** 2024-01-01 | **Creator:** Test User 1 (22222) -**Thread:** 12345 -**Channel:** Test Channel (67890) +**Thread:** thread-id-1 +**Channel:** Test Channel (channel-id-1) Filtered result " diff --git a/src/tools/__tests__/__snapshots__/update-object.test.ts.snap b/src/tools/__tests__/__snapshots__/update-object.test.ts.snap index e173b8f..9f3cd87 100644 --- a/src/tools/__tests__/__snapshots__/update-object.test.ts.snap +++ b/src/tools/__tests__/__snapshots__/update-object.test.ts.snap @@ -3,11 +3,11 @@ exports[`update-object tool targetType: comment should update a comment content 1`] = ` "# Comment Updated -**Comment ID:** 54321 -**Thread ID:** 12345 -**Channel ID:** 67890 +**Comment ID:** comment-id-1 +**Thread ID:** thread-id-1 +**Channel ID:** channel-id-1 **Last Edited:** 2025-02-03T12:34:56.000Z -**URL:** https://twist.com/a/11111/ch/67890/t/12345/c/54321 +**URL:** https://comms.todoist.com/a/11111/ch/channel-id-1/t/thread-id-1/c/comment-id-1 ## Content @@ -17,11 +17,11 @@ Updated comment content" exports[`update-object tool targetType: message should update a conversation message content 1`] = ` "# Message Updated -**Message ID:** 98765 -**Conversation ID:** 33333 +**Message ID:** msg-id-1 +**Conversation ID:** conv-id-1 **Workspace ID:** 11111 **Last Edited:** 2025-02-03T12:34:56.000Z -**URL:** https://twist.com/a/11111/msg/33333/m/98765 +**URL:** https://comms.todoist.com/a/11111/msg/conv-id-1/m/msg-id-1 ## Content @@ -32,10 +32,10 @@ exports[`update-object tool targetType: thread should update a thread title and "# Thread Updated **Title:** Updated Title -**Thread ID:** 12345 -**Channel ID:** 67890 +**Thread ID:** thread-id-1 +**Channel ID:** channel-id-1 **Last Edited:** 2025-02-03T12:34:56.000Z -**URL:** https://twist.com/a/11111/ch/67890/t/12345/ +**URL:** https://comms.todoist.com/a/11111/ch/channel-id-1/t/thread-id-1/ ## Content @@ -46,9 +46,9 @@ exports[`update-object tool targetType: thread should update only the thread con "# Thread Updated **Title:** Test Thread -**Thread ID:** 12345 -**Channel ID:** 67890 -**URL:** https://twist.com/a/11111/ch/67890/t/12345/ +**Thread ID:** thread-id-1 +**Channel ID:** channel-id-1 +**URL:** https://comms.todoist.com/a/11111/ch/channel-id-1/t/thread-id-1/ ## Content @@ -59,9 +59,9 @@ exports[`update-object tool targetType: thread should update only the thread tit "# Thread Updated **Title:** New Title Only -**Thread ID:** 12345 -**Channel ID:** 67890 -**URL:** https://twist.com/a/11111/ch/67890/t/12345/ +**Thread ID:** thread-id-1 +**Channel ID:** channel-id-1 +**URL:** https://comms.todoist.com/a/11111/ch/channel-id-1/t/thread-id-1/ ## Content diff --git a/src/tools/__tests__/away.test.ts b/src/tools/__tests__/away.test.ts deleted file mode 100644 index dd8d82c..0000000 --- a/src/tools/__tests__/away.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { AWAY_MODE_TYPES, type AwayModeType, type TwistApi } from '@doist/twist-sdk' -import { jest } from '@jest/globals' -import { - createMockUser, - extractStructuredContent, - extractTextContent, - TEST_ERRORS, -} from '../../utils/test-helpers.js' -import { ToolNames } from '../../utils/tool-names.js' -import { away } from '../away.js' - -const mockTwistApi = { - users: { - getSessionUser: jest.fn(), - update: jest.fn(), - }, -} as unknown as jest.Mocked - -const { AWAY } = ToolNames - -describe(`${AWAY} tool`, () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - describe('get action', () => { - it('should return away status when user is away', async () => { - const mockUser = createMockUser({ - awayMode: { - type: 'vacation', - dateFrom: '2025-01-10', - dateTo: '2025-01-20', - }, - }) - mockTwistApi.users.getSessionUser.mockResolvedValue(mockUser) - - const result = await away.execute({ action: 'get' }, mockTwistApi) - - expect(mockTwistApi.users.getSessionUser).toHaveBeenCalledWith() - - const textContent = extractTextContent(result) - expect(textContent).toContain('**Status:** Away') - expect(textContent).toContain('**Type:** Vacation') - expect(textContent).toContain('**From:** 2025-01-10') - expect(textContent).toContain('**To:** 2025-01-20') - - const structured = extractStructuredContent(result) - expect(structured).toEqual( - expect.objectContaining({ - type: 'away_status', - action: 'get', - isAway: true, - awayMode: { - type: 'vacation', - dateFrom: '2025-01-10', - dateTo: '2025-01-20', - }, - }), - ) - }) - - it('should return not away when user has no away mode', async () => { - const mockUser = createMockUser({ awayMode: undefined }) - mockTwistApi.users.getSessionUser.mockResolvedValue(mockUser) - - const result = await away.execute({ action: 'get' }, mockTwistApi) - - const textContent = extractTextContent(result) - expect(textContent).toContain('**Status:** Not away') - - const structured = extractStructuredContent(result) - expect(structured).toEqual( - expect.objectContaining({ - type: 'away_status', - action: 'get', - isAway: false, - }), - ) - }) - }) - - describe('set action', () => { - it('should set away mode with explicit from date', async () => { - mockTwistApi.users.update.mockResolvedValue(createMockUser()) - - const result = await away.execute( - { - action: 'set', - type: 'vacation', - from: '2025-03-01', - until: '2025-03-15', - }, - mockTwistApi, - ) - - expect(mockTwistApi.users.update).toHaveBeenCalledWith({ - awayMode: { - type: 'vacation', - dateFrom: '2025-03-01', - dateTo: '2025-03-15', - }, - }) - - const textContent = extractTextContent(result) - expect(textContent).toContain('**Type:** Vacation') - expect(textContent).toContain('**From:** 2025-03-01') - expect(textContent).toContain('**To:** 2025-03-15') - - const structured = extractStructuredContent(result) - expect(structured).toEqual( - expect.objectContaining({ - type: 'away_status', - action: 'set', - isAway: true, - }), - ) - }) - - it('should default from date to today when not provided', async () => { - mockTwistApi.users.update.mockResolvedValue(createMockUser()) - - // Mock the date to ensure consistent test results - jest.useFakeTimers() - jest.setSystemTime(new Date('2025-06-15T12:00:00Z')) - - const result = await away.execute( - { - action: 'set', - type: 'sickleave', - until: '2025-06-20', - }, - mockTwistApi, - ) - - jest.useRealTimers() - - expect(mockTwistApi.users.update).toHaveBeenCalledWith({ - awayMode: { - type: 'sickleave', - dateFrom: '2025-06-15', - dateTo: '2025-06-20', - }, - }) - - const textContent = extractTextContent(result) - expect(textContent).toContain('**Type:** Sick leave') - }) - - it('should throw error when type is missing', async () => { - await expect( - away.execute( - { action: 'set', until: '2025-03-15' } as Parameters[0], - mockTwistApi, - ), - ).rejects.toThrow('The "type" parameter is required when action is "set".') - }) - - it('should throw error when until is missing', async () => { - await expect( - away.execute( - { action: 'set', type: 'vacation' } as Parameters[0], - mockTwistApi, - ), - ).rejects.toThrow('The "until" parameter is required when action is "set".') - }) - - it('should support all away mode types', async () => { - mockTwistApi.users.update.mockResolvedValue(createMockUser()) - - const expectedLabels: Record = { - parental: 'Parental leave', - vacation: 'Vacation', - sickleave: 'Sick leave', - other: 'Away', - } - - for (const awayType of AWAY_MODE_TYPES) { - const result = await away.execute( - { - action: 'set', - type: awayType, - from: '2025-01-01', - until: '2025-01-10', - }, - mockTwistApi, - ) - - const textContent = extractTextContent(result) - expect(textContent).toContain(`**Type:** ${expectedLabels[awayType]}`) - } - }) - }) - - describe('clear action', () => { - it('should clear away mode', async () => { - mockTwistApi.users.update.mockResolvedValue(createMockUser()) - - const result = await away.execute({ action: 'clear' }, mockTwistApi) - - expect(mockTwistApi.users.update).toHaveBeenCalledWith({ - awayMode: '' as never, - }) - - const textContent = extractTextContent(result) - expect(textContent).toContain('Away Status Cleared') - expect(textContent).toContain('**Status:** Not away') - - const structured = extractStructuredContent(result) - expect(structured).toEqual( - expect.objectContaining({ - type: 'away_status', - action: 'clear', - isAway: false, - }), - ) - }) - }) - - describe('error handling', () => { - it('should propagate API errors on get', async () => { - mockTwistApi.users.getSessionUser.mockRejectedValue( - new Error(TEST_ERRORS.API_UNAUTHORIZED), - ) - - await expect(away.execute({ action: 'get' }, mockTwistApi)).rejects.toThrow( - TEST_ERRORS.API_UNAUTHORIZED, - ) - }) - - it('should propagate API errors on set', async () => { - mockTwistApi.users.update.mockRejectedValue(new Error(TEST_ERRORS.API_RATE_LIMIT)) - - await expect( - away.execute( - { - action: 'set', - type: 'vacation', - from: '2025-01-01', - until: '2025-01-10', - }, - mockTwistApi, - ), - ).rejects.toThrow(TEST_ERRORS.API_RATE_LIMIT) - }) - - it('should propagate API errors on clear', async () => { - mockTwistApi.users.update.mockRejectedValue(new Error(TEST_ERRORS.API_UNAUTHORIZED)) - - await expect(away.execute({ action: 'clear' }, mockTwistApi)).rejects.toThrow( - TEST_ERRORS.API_UNAUTHORIZED, - ) - }) - }) -}) diff --git a/src/tools/__tests__/build-link.test.ts b/src/tools/__tests__/build-link.test.ts index 59ef8c1..a6066ea 100644 --- a/src/tools/__tests__/build-link.test.ts +++ b/src/tools/__tests__/build-link.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { extractTextContent } from '../../utils/test-helpers.js' import { buildLink } from '../build-link.js' @@ -11,14 +11,14 @@ describe('buildLink', () => { conversationId: 456, fullUrl: true, }, - {} as TwistApi, + {} as CommsApi, ) const textContent = extractTextContent(result) - expect(textContent).toBe('https://twist.com/a/123/msg/456/') + expect(textContent).toBe('https://comms.todoist.com/a/123/msg/456/') expect(result.structuredContent?.type).toBe('link_data') expect(result.structuredContent?.linkType).toBe('conversation') - expect(result.structuredContent?.url).toBe('https://twist.com/a/123/msg/456/') + expect(result.structuredContent?.url).toBe('https://comms.todoist.com/a/123/msg/456/') }) test('builds a message link', async () => { @@ -29,11 +29,11 @@ describe('buildLink', () => { messageId: 789, fullUrl: true, }, - {} as TwistApi, + {} as CommsApi, ) const textContent = extractTextContent(result) - expect(textContent).toBe('https://twist.com/a/123/msg/456/m/789') + expect(textContent).toBe('https://comms.todoist.com/a/123/msg/456/m/789') expect(result.structuredContent?.linkType).toBe('message') }) @@ -44,7 +44,7 @@ describe('buildLink', () => { conversationId: 456, fullUrl: false, }, - {} as TwistApi, + {} as CommsApi, ) const textContent = extractTextContent(result) @@ -61,11 +61,11 @@ describe('buildLink', () => { threadId: 789, fullUrl: true, }, - {} as TwistApi, + {} as CommsApi, ) const textContent = extractTextContent(result) - expect(textContent).toBe('https://twist.com/a/123/ch/42/t/789/') + expect(textContent).toBe('https://comms.todoist.com/a/123/ch/42/t/789/') expect(result.structuredContent?.linkType).toBe('thread') }) @@ -76,11 +76,11 @@ describe('buildLink', () => { threadId: 789, fullUrl: true, }, - {} as TwistApi, + {} as CommsApi, ) const textContent = extractTextContent(result) - expect(textContent).toBe('https://twist.com/a/123/inbox/t/789/') + expect(textContent).toBe('https://comms.todoist.com/a/123/inbox/t/789/') expect(result.structuredContent?.linkType).toBe('thread') }) @@ -93,11 +93,11 @@ describe('buildLink', () => { commentId: 999, fullUrl: true, }, - {} as TwistApi, + {} as CommsApi, ) const textContent = extractTextContent(result) - expect(textContent).toBe('https://twist.com/a/123/ch/42/t/789/c/999') + expect(textContent).toBe('https://comms.todoist.com/a/123/ch/42/t/789/c/999') expect(result.structuredContent?.linkType).toBe('comment') }) @@ -110,7 +110,7 @@ describe('buildLink', () => { commentId: 999, fullUrl: true, }, - {} as TwistApi, + {} as CommsApi, ), ).rejects.toThrow('channelId is required when building a comment link') }) @@ -124,7 +124,7 @@ describe('buildLink', () => { workspaceId: 123, fullUrl: true, }, - {} as TwistApi, + {} as CommsApi, ), ).rejects.toThrow('Must provide either conversationId OR threadId to build a link') }) @@ -140,7 +140,7 @@ describe('buildLink', () => { commentId: 999, fullUrl: true, }, - {} as TwistApi, + {} as CommsApi, ) expect(result.structuredContent?.params).toEqual({ diff --git a/src/tools/__tests__/create-thread.test.ts b/src/tools/__tests__/create-thread.test.ts index 63da0db..ad93bb9 100644 --- a/src/tools/__tests__/create-thread.test.ts +++ b/src/tools/__tests__/create-thread.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { createMockThread, @@ -9,11 +9,11 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { createThread } from '../create-thread.js' -const mockTwistApi = { +const mockCommsApi = { threads: { createThread: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { CREATE_THREAD } = ToolNames @@ -28,7 +28,7 @@ describe(`${CREATE_THREAD} tool`, () => { title: 'New Discussion', content: 'Let us discuss this topic', }) - mockTwistApi.threads.createThread.mockResolvedValue(mockThread) + mockCommsApi.threads.createThread.mockResolvedValue(mockThread) const result = await createThread.execute( { @@ -36,10 +36,10 @@ describe(`${CREATE_THREAD} tool`, () => { title: 'New Discussion', content: 'Let us discuss this topic', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.createThread).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.createThread).toHaveBeenCalledWith({ channelId: TEST_IDS.CHANNEL_1, title: 'New Discussion', content: 'Let us discuss this topic', @@ -59,7 +59,7 @@ describe(`${CREATE_THREAD} tool`, () => { channelId: TEST_IDS.CHANNEL_1, workspaceId: TEST_IDS.WORKSPACE_1, content: 'Let us discuss this topic', - threadUrl: expect.stringContaining('twist.com'), + threadUrl: expect.stringContaining('comms.todoist.com'), }), ) }) @@ -69,7 +69,7 @@ describe(`${CREATE_THREAD} tool`, () => { title: 'Notify Users', content: 'Important update', }) - mockTwistApi.threads.createThread.mockResolvedValue(mockThread) + mockCommsApi.threads.createThread.mockResolvedValue(mockThread) const result = await createThread.execute( { @@ -78,10 +78,10 @@ describe(`${CREATE_THREAD} tool`, () => { content: 'Important update', recipients: [TEST_IDS.USER_1, TEST_IDS.USER_2], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.createThread).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.createThread).toHaveBeenCalledWith({ channelId: TEST_IDS.CHANNEL_1, title: 'Notify Users', content: 'Important update', @@ -101,7 +101,7 @@ describe(`${CREATE_THREAD} tool`, () => { title: 'Notify Groups', content: 'Important group update', }) - mockTwistApi.threads.createThread.mockResolvedValue(mockThread) + mockCommsApi.threads.createThread.mockResolvedValue(mockThread) const result = await createThread.execute( { @@ -110,10 +110,10 @@ describe(`${CREATE_THREAD} tool`, () => { content: 'Important group update', groups: [100, 200], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.createThread).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.createThread).toHaveBeenCalledWith({ channelId: TEST_IDS.CHANNEL_1, title: 'Notify Groups', content: 'Important group update', @@ -131,7 +131,7 @@ describe(`${CREATE_THREAD} tool`, () => { title: 'Empty Groups', content: 'No group recipients', }) - mockTwistApi.threads.createThread.mockResolvedValue(mockThread) + mockCommsApi.threads.createThread.mockResolvedValue(mockThread) const result = await createThread.execute( { @@ -140,10 +140,10 @@ describe(`${CREATE_THREAD} tool`, () => { content: 'No group recipients', groups: [], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.createThread).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.createThread).toHaveBeenCalledWith({ channelId: TEST_IDS.CHANNEL_1, title: 'Empty Groups', content: 'No group recipients', @@ -161,7 +161,7 @@ describe(`${CREATE_THREAD} tool`, () => { title: 'Notify Users and Groups', content: 'Important broad update', }) - mockTwistApi.threads.createThread.mockResolvedValue(mockThread) + mockCommsApi.threads.createThread.mockResolvedValue(mockThread) const result = await createThread.execute( { @@ -171,10 +171,10 @@ describe(`${CREATE_THREAD} tool`, () => { recipients: [TEST_IDS.USER_1], groups: [100], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.createThread).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.createThread).toHaveBeenCalledWith({ channelId: TEST_IDS.CHANNEL_1, title: 'Notify Users and Groups', content: 'Important broad update', @@ -191,7 +191,7 @@ describe(`${CREATE_THREAD} tool`, () => { describe('error handling', () => { it('should propagate API errors', async () => { const apiError = new Error('Channel not found') - mockTwistApi.threads.createThread.mockRejectedValue(apiError) + mockCommsApi.threads.createThread.mockRejectedValue(apiError) await expect( createThread.execute( @@ -200,7 +200,7 @@ describe(`${CREATE_THREAD} tool`, () => { title: 'Test Thread', content: 'Test content', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Channel not found') }) diff --git a/src/tools/__tests__/delete-object.test.ts b/src/tools/__tests__/delete-object.test.ts index 70e14c6..a951981 100644 --- a/src/tools/__tests__/delete-object.test.ts +++ b/src/tools/__tests__/delete-object.test.ts @@ -1,10 +1,10 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { extractTextContent, TEST_IDS } from '../../utils/test-helpers.js' import { ToolNames } from '../../utils/tool-names.js' import { deleteObject } from '../delete-object.js' -const mockTwistApi = { +const mockCommsApi = { threads: { deleteThread: jest.fn(), }, @@ -14,7 +14,7 @@ const mockTwistApi = { conversationMessages: { deleteMessage: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { DELETE_OBJECT } = ToolNames @@ -25,17 +25,17 @@ describe(`${DELETE_OBJECT} tool`, () => { describe('targetType: thread', () => { it('should delete a thread by ID', async () => { - ;(mockTwistApi.threads.deleteThread as jest.Mock).mockResolvedValue(undefined as never) + ;(mockCommsApi.threads.deleteThread as jest.Mock).mockResolvedValue(undefined as never) const result = await deleteObject.execute( { targetType: 'thread', targetId: TEST_IDS.THREAD_1, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.deleteThread).toHaveBeenCalledWith(TEST_IDS.THREAD_1) + expect(mockCommsApi.threads.deleteThread).toHaveBeenCalledWith(TEST_IDS.THREAD_1) expect(extractTextContent(result)).toMatchSnapshot() const { structuredContent } = result @@ -48,7 +48,7 @@ describe(`${DELETE_OBJECT} tool`, () => { }) it('should propagate API errors when deleting a thread', async () => { - ;(mockTwistApi.threads.deleteThread as jest.Mock).mockRejectedValue( + ;(mockCommsApi.threads.deleteThread as jest.Mock).mockRejectedValue( new Error('Thread not found') as never, ) @@ -58,7 +58,7 @@ describe(`${DELETE_OBJECT} tool`, () => { targetType: 'thread', targetId: TEST_IDS.THREAD_1, }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Thread not found') }) @@ -66,7 +66,7 @@ describe(`${DELETE_OBJECT} tool`, () => { describe('targetType: comment', () => { it('should delete a comment by ID', async () => { - ;(mockTwistApi.comments.deleteComment as jest.Mock).mockResolvedValue( + ;(mockCommsApi.comments.deleteComment as jest.Mock).mockResolvedValue( undefined as never, ) @@ -75,10 +75,10 @@ describe(`${DELETE_OBJECT} tool`, () => { targetType: 'comment', targetId: TEST_IDS.COMMENT_1, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.deleteComment).toHaveBeenCalledWith(TEST_IDS.COMMENT_1) + expect(mockCommsApi.comments.deleteComment).toHaveBeenCalledWith(TEST_IDS.COMMENT_1) expect(extractTextContent(result)).toMatchSnapshot() const { structuredContent } = result @@ -91,7 +91,7 @@ describe(`${DELETE_OBJECT} tool`, () => { }) it('should propagate API errors when deleting a comment', async () => { - ;(mockTwistApi.comments.deleteComment as jest.Mock).mockRejectedValue( + ;(mockCommsApi.comments.deleteComment as jest.Mock).mockRejectedValue( new Error('Comment not found') as never, ) @@ -101,7 +101,7 @@ describe(`${DELETE_OBJECT} tool`, () => { targetType: 'comment', targetId: TEST_IDS.COMMENT_1, }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Comment not found') }) @@ -109,7 +109,7 @@ describe(`${DELETE_OBJECT} tool`, () => { describe('targetType: message', () => { it('should delete a conversation message by ID', async () => { - ;(mockTwistApi.conversationMessages.deleteMessage as jest.Mock).mockResolvedValue( + ;(mockCommsApi.conversationMessages.deleteMessage as jest.Mock).mockResolvedValue( undefined as never, ) @@ -118,10 +118,10 @@ describe(`${DELETE_OBJECT} tool`, () => { targetType: 'message', targetId: TEST_IDS.MESSAGE_1, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.conversationMessages.deleteMessage).toHaveBeenCalledWith( + expect(mockCommsApi.conversationMessages.deleteMessage).toHaveBeenCalledWith( TEST_IDS.MESSAGE_1, ) expect(extractTextContent(result)).toMatchSnapshot() @@ -136,7 +136,7 @@ describe(`${DELETE_OBJECT} tool`, () => { }) it('should propagate API errors when deleting a message', async () => { - ;(mockTwistApi.conversationMessages.deleteMessage as jest.Mock).mockRejectedValue( + ;(mockCommsApi.conversationMessages.deleteMessage as jest.Mock).mockRejectedValue( new Error('Message not found') as never, ) @@ -146,7 +146,7 @@ describe(`${DELETE_OBJECT} tool`, () => { targetType: 'message', targetId: TEST_IDS.MESSAGE_1, }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Message not found') }) @@ -174,25 +174,25 @@ describe(`${DELETE_OBJECT} tool`, () => { it.each(routingCases)( 'should only call $expectedMethod when targetType is $targetType', async ({ targetType, targetId }) => { - ;(mockTwistApi.threads.deleteThread as jest.Mock).mockResolvedValue( + ;(mockCommsApi.threads.deleteThread as jest.Mock).mockResolvedValue( undefined as never, ) - ;(mockTwistApi.comments.deleteComment as jest.Mock).mockResolvedValue( + ;(mockCommsApi.comments.deleteComment as jest.Mock).mockResolvedValue( undefined as never, ) - ;(mockTwistApi.conversationMessages.deleteMessage as jest.Mock).mockResolvedValue( + ;(mockCommsApi.conversationMessages.deleteMessage as jest.Mock).mockResolvedValue( undefined as never, ) - await deleteObject.execute({ targetType, targetId }, mockTwistApi) + await deleteObject.execute({ targetType, targetId }, mockCommsApi) - expect(mockTwistApi.threads.deleteThread).toHaveBeenCalledTimes( + expect(mockCommsApi.threads.deleteThread).toHaveBeenCalledTimes( targetType === 'thread' ? 1 : 0, ) - expect(mockTwistApi.comments.deleteComment).toHaveBeenCalledTimes( + expect(mockCommsApi.comments.deleteComment).toHaveBeenCalledTimes( targetType === 'comment' ? 1 : 0, ) - expect(mockTwistApi.conversationMessages.deleteMessage).toHaveBeenCalledTimes( + expect(mockCommsApi.conversationMessages.deleteMessage).toHaveBeenCalledTimes( targetType === 'message' ? 1 : 0, ) }, diff --git a/src/tools/__tests__/fetch-inbox.test.ts b/src/tools/__tests__/fetch-inbox.test.ts index 885b38b..3b95460 100644 --- a/src/tools/__tests__/fetch-inbox.test.ts +++ b/src/tools/__tests__/fetch-inbox.test.ts @@ -1,12 +1,11 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { extractTextContent, TEST_IDS } from '../../utils/test-helpers.js' import { ToolNames } from '../../utils/tool-names.js' import { fetchInbox } from '../fetch-inbox.js' -// Mock the Twist API -const mockTwistApi = { - batch: jest.fn(), +// Mock the Comms API +const mockCommsApi = { inbox: { getInbox: jest.fn(), getCount: jest.fn(), @@ -24,123 +23,103 @@ const mockTwistApi = { workspaceUsers: { getUserById: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { FETCH_INBOX } = ToolNames +function makeInboxThread(overrides: Partial> = {}) { + return { + id: TEST_IDS.THREAD_1, + title: 'Test Thread', + content: 'Thread content', + creator: TEST_IDS.USER_1, + channelId: TEST_IDS.CHANNEL_1, + workspaceId: TEST_IDS.WORKSPACE_1, + commentCount: 0, + lastUpdated: new Date(), + posted: new Date(), + snippet: 'snippet', + snippetCreator: TEST_IDS.USER_1, + isSaved: false, + pinned: false, + isArchived: false, + inInbox: true, + closed: false, + url: `https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_1}/`, + ...overrides, + } +} + +function makeChannel(overrides: Partial> = {}) { + return { + id: TEST_IDS.CHANNEL_1, + name: 'Test Channel', + workspaceId: TEST_IDS.WORKSPACE_1, + created: new Date(), + archived: false, + public: true, + color: 0, + creator: TEST_IDS.USER_1, + version: 1, + ...overrides, + } +} + describe(`${FETCH_INBOX} tool`, () => { beforeEach(() => { jest.clearAllMocks() - // Mock batch to return responses with .data property - mockTwistApi.batch.mockImplementation(async (...args: readonly unknown[]) => { - const results = [] - for (const arg of args) { - const result = await arg - results.push({ data: result }) - } - return results as never - }) }) describe('fetching inbox successfully', () => { it('should fetch inbox with threads', async () => { - mockTwistApi.inbox.getInbox.mockResolvedValue([ - { - id: TEST_IDS.THREAD_1, - title: 'Test Thread 1', - content: 'Thread content 1', - creator: TEST_IDS.USER_1, - channelId: TEST_IDS.CHANNEL_1, - workspaceId: TEST_IDS.WORKSPACE_1, - commentCount: 3, - lastUpdated: new Date(), - posted: new Date(), - snippet: 'Thread snippet 1', - snippetCreator: TEST_IDS.USER_1, - starred: false, - pinned: false, - isArchived: false, - inInbox: true, - closed: false, - url: `https://twist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_1}/`, - }, - { + mockCommsApi.inbox.getInbox.mockResolvedValue([ + makeInboxThread({ id: TEST_IDS.THREAD_1, title: 'Test Thread 1', commentCount: 3 }), + makeInboxThread({ id: TEST_IDS.THREAD_2, title: 'Test Thread 2', - content: 'Thread content 2', creator: TEST_IDS.USER_2, - channelId: TEST_IDS.CHANNEL_1, - workspaceId: TEST_IDS.WORKSPACE_1, - commentCount: 0, - lastUpdated: new Date(), - posted: new Date(), - snippet: 'Thread snippet 2', snippetCreator: TEST_IDS.USER_2, - starred: true, - pinned: false, - isArchived: false, - inInbox: true, - closed: false, - url: `https://twist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_2}/`, - }, - ]) - mockTwistApi.inbox.getCount.mockResolvedValue(5) - mockTwistApi.threads.getUnread.mockResolvedValue([ - { - threadId: TEST_IDS.THREAD_1, - channelId: TEST_IDS.CHANNEL_1, - objIndex: 100, - directMention: false, - }, + isSaved: true, + url: `https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_2}/`, + }), ]) - mockTwistApi.conversations.getUnread.mockResolvedValue([]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: TEST_IDS.CHANNEL_1, - name: 'Test Channel', - workspaceId: TEST_IDS.WORKSPACE_1, - created: new Date(), - archived: false, - public: true, - color: 0, - creator: TEST_IDS.USER_1, + mockCommsApi.inbox.getCount.mockResolvedValue(5) + mockCommsApi.threads.getUnread.mockResolvedValue({ + data: [ + { + threadId: TEST_IDS.THREAD_1, + channelId: TEST_IDS.CHANNEL_1, + objIndex: 100, + directMention: false, + }, + ], version: 1, }) + mockCommsApi.conversations.getUnread.mockResolvedValue({ data: [], version: 1 }) + mockCommsApi.channels.getChannel.mockResolvedValue(makeChannel()) const result = await fetchInbox.execute( { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, onlyUnread: false }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.inbox.getInbox).toHaveBeenCalledWith( - { - workspaceId: TEST_IDS.WORKSPACE_1, - since: undefined, - until: undefined, - limit: 50, - archiveFilter: 'active', - }, - { batch: true }, - ) - expect(mockTwistApi.inbox.getCount).toHaveBeenCalledWith(TEST_IDS.WORKSPACE_1, { - batch: true, - }) - expect(mockTwistApi.threads.getUnread).toHaveBeenCalledWith(TEST_IDS.WORKSPACE_1, { - batch: true, - }) - expect(mockTwistApi.conversations.getUnread).toHaveBeenCalledWith( - TEST_IDS.WORKSPACE_1, - { - batch: true, - }, - ) - // Verify channel info is fetched for each thread - expect(mockTwistApi.channels.getChannel).toHaveBeenCalledWith(TEST_IDS.CHANNEL_1, { - batch: true, + expect(mockCommsApi.inbox.getInbox).toHaveBeenCalledWith({ + workspaceId: TEST_IDS.WORKSPACE_1, + newerThan: undefined, + olderThan: undefined, + limit: 50, + archiveFilter: 'active', }) + expect(mockCommsApi.inbox.getCount).toHaveBeenCalledWith(TEST_IDS.WORKSPACE_1) + expect(mockCommsApi.threads.getUnread).toHaveBeenCalledWith(TEST_IDS.WORKSPACE_1) + expect(mockCommsApi.conversations.getUnread).toHaveBeenCalledWith(TEST_IDS.WORKSPACE_1) + expect(mockCommsApi.channels.getChannel).toHaveBeenCalledWith(TEST_IDS.CHANNEL_1) + // Two threads share a channel — verify we hit `getChannel` once, + // not per-thread, so the inbox stays cheap as it scales. + expect(mockCommsApi.channels.getChannel).toHaveBeenCalledTimes(1) expect(extractTextContent(result)).toMatchSnapshot() - // Verify structured content const { structuredContent } = result expect(structuredContent).toEqual( expect.objectContaining({ @@ -157,78 +136,41 @@ describe(`${FETCH_INBOX} tool`, () => { if (threads?.[0] && threads[1]) { expect(threads[0].id).toBe(TEST_IDS.THREAD_1) expect(threads[0].channelName).toBe('Test Channel') - expect(threads[0].threadUrl).toContain('twist.com') + expect(threads[0].threadUrl).toContain('comms.todoist.com') expect(threads[0].isUnread).toBe(true) expect(threads[1].isStarred).toBe(true) } }) it('should filter only unread items when requested', async () => { - mockTwistApi.inbox.getInbox.mockResolvedValue([ - { - id: TEST_IDS.THREAD_1, - title: 'Unread Thread', - content: 'Unread content', - creator: TEST_IDS.USER_1, - channelId: TEST_IDS.CHANNEL_1, - workspaceId: TEST_IDS.WORKSPACE_1, - commentCount: 3, - lastUpdated: new Date(), - posted: new Date(), - snippet: 'Unread snippet', - snippetCreator: TEST_IDS.USER_1, - starred: false, - pinned: false, - isArchived: false, - inInbox: true, - closed: false, - url: `https://twist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_1}/`, - }, - { + mockCommsApi.inbox.getInbox.mockResolvedValue([ + makeInboxThread({ id: TEST_IDS.THREAD_1, title: 'Unread Thread' }), + makeInboxThread({ id: TEST_IDS.THREAD_2, title: 'Read Thread', - content: 'Read content', creator: TEST_IDS.USER_2, - channelId: TEST_IDS.CHANNEL_1, - workspaceId: TEST_IDS.WORKSPACE_1, - commentCount: 0, - lastUpdated: new Date(), - posted: new Date(), - snippet: 'Read snippet', snippetCreator: TEST_IDS.USER_2, - starred: false, - pinned: false, - isArchived: false, - inInbox: true, - closed: false, - url: `https://twist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_2}/`, - }, - ]) - mockTwistApi.inbox.getCount.mockResolvedValue(1) - mockTwistApi.threads.getUnread.mockResolvedValue([ - { - threadId: TEST_IDS.THREAD_1, - channelId: TEST_IDS.CHANNEL_1, - objIndex: 100, - directMention: false, - }, + url: `https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_2}/`, + }), ]) - mockTwistApi.conversations.getUnread.mockResolvedValue([]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: TEST_IDS.CHANNEL_1, - name: 'Test Channel', - workspaceId: TEST_IDS.WORKSPACE_1, - created: new Date(), - archived: false, - public: true, - color: 0, - creator: TEST_IDS.USER_1, + mockCommsApi.inbox.getCount.mockResolvedValue(1) + mockCommsApi.threads.getUnread.mockResolvedValue({ + data: [ + { + threadId: TEST_IDS.THREAD_1, + channelId: TEST_IDS.CHANNEL_1, + objIndex: 100, + directMention: false, + }, + ], version: 1, }) + mockCommsApi.conversations.getUnread.mockResolvedValue({ data: [], version: 1 }) + mockCommsApi.channels.getChannel.mockResolvedValue(makeChannel()) const result = await fetchInbox.execute( { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, onlyUnread: true }, - mockTwistApi, + mockCommsApi, ) expect(extractTextContent(result)).toMatchSnapshot() @@ -237,24 +179,24 @@ describe(`${FETCH_INBOX} tool`, () => { }) it('should handle empty inbox', async () => { - mockTwistApi.inbox.getInbox.mockResolvedValue([]) - mockTwistApi.inbox.getCount.mockResolvedValue(0) - mockTwistApi.threads.getUnread.mockResolvedValue([]) - mockTwistApi.conversations.getUnread.mockResolvedValue([]) + mockCommsApi.inbox.getInbox.mockResolvedValue([]) + mockCommsApi.inbox.getCount.mockResolvedValue(0) + mockCommsApi.threads.getUnread.mockResolvedValue({ data: [], version: 1 }) + mockCommsApi.conversations.getUnread.mockResolvedValue({ data: [], version: 1 }) const result = await fetchInbox.execute( { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, onlyUnread: false }, - mockTwistApi, + mockCommsApi, ) expect(extractTextContent(result)).toMatchSnapshot() }) it('should filter by date range', async () => { - mockTwistApi.inbox.getInbox.mockResolvedValue([]) - mockTwistApi.inbox.getCount.mockResolvedValue(0) - mockTwistApi.threads.getUnread.mockResolvedValue([]) - mockTwistApi.conversations.getUnread.mockResolvedValue([]) + mockCommsApi.inbox.getInbox.mockResolvedValue([]) + mockCommsApi.inbox.getCount.mockResolvedValue(0) + mockCommsApi.threads.getUnread.mockResolvedValue({ data: [], version: 1 }) + mockCommsApi.conversations.getUnread.mockResolvedValue({ data: [], version: 1 }) const result = await fetchInbox.execute( { @@ -264,38 +206,39 @@ describe(`${FETCH_INBOX} tool`, () => { limit: 50, onlyUnread: false, }, - mockTwistApi, + mockCommsApi, ) - // Verify dates were converted to Date objects - expect(mockTwistApi.inbox.getInbox).toHaveBeenCalledWith( + expect(mockCommsApi.inbox.getInbox).toHaveBeenCalledWith( expect.objectContaining({ - since: expect.any(Date), - until: expect.any(Date), + newerThan: expect.any(Date), + olderThan: expect.any(Date), }), - { batch: true }, ) expect(extractTextContent(result)).toMatchSnapshot() }) it('should fetch inbox with unread conversations', async () => { - mockTwistApi.inbox.getInbox.mockResolvedValue([]) - mockTwistApi.inbox.getCount.mockResolvedValue(0) - mockTwistApi.threads.getUnread.mockResolvedValue([]) - mockTwistApi.conversations.getUnread.mockResolvedValue([ - { - conversationId: TEST_IDS.CONVERSATION_1, - objIndex: 5, - directMention: false, - }, - { - conversationId: TEST_IDS.CONVERSATION_2, - objIndex: 3, - directMention: true, - }, - ]) - mockTwistApi.conversations.getConversation.mockImplementation((id: number) => { + mockCommsApi.inbox.getInbox.mockResolvedValue([]) + mockCommsApi.inbox.getCount.mockResolvedValue(0) + mockCommsApi.threads.getUnread.mockResolvedValue({ data: [], version: 1 }) + mockCommsApi.conversations.getUnread.mockResolvedValue({ + data: [ + { + conversationId: TEST_IDS.CONVERSATION_1, + objIndex: 5, + directMention: false, + }, + { + conversationId: TEST_IDS.CONVERSATION_2, + objIndex: 3, + directMention: true, + }, + ], + version: 1, + }) + mockCommsApi.conversations.getConversation.mockImplementation((id: string) => { if (id === TEST_IDS.CONVERSATION_1) { return Promise.resolve({ id: TEST_IDS.CONVERSATION_1, @@ -309,7 +252,7 @@ describe(`${FETCH_INBOX} tool`, () => { archived: false, created: new Date(), creator: TEST_IDS.USER_1, - url: `https://twist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_1}/`, + url: `https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_1}/`, }) as never } return Promise.resolve({ @@ -325,43 +268,40 @@ describe(`${FETCH_INBOX} tool`, () => { archived: false, created: new Date(), creator: TEST_IDS.USER_1, - url: `https://twist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_2}/`, + url: `https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_2}/`, }) as never }) - mockTwistApi.workspaceUsers.getUserById.mockImplementation( + mockCommsApi.workspaceUsers.getUserById.mockImplementation( (args: { workspaceId: number; userId: number }) => { if (args.userId === TEST_IDS.USER_1) { return Promise.resolve({ id: TEST_IDS.USER_1, - name: 'Alice', + fullName: 'Alice', shortName: 'Alice', - bot: false, timezone: 'UTC', removed: false, - userType: 'MEMBER' as const, + userType: 'USER' as const, version: 1, }) as never } if (args.userId === TEST_IDS.USER_2) { return Promise.resolve({ id: TEST_IDS.USER_2, - name: 'Bob', + fullName: 'Bob', shortName: 'Bob', - bot: false, timezone: 'UTC', removed: false, - userType: 'MEMBER' as const, + userType: 'USER' as const, version: 1, }) as never } return Promise.resolve({ id: TEST_IDS.USER_3, - name: 'Charlie', + fullName: 'Charlie', shortName: 'Charlie', - bot: false, timezone: 'UTC', removed: false, - userType: 'MEMBER' as const, + userType: 'USER' as const, version: 1, }) as never }, @@ -369,7 +309,7 @@ describe(`${FETCH_INBOX} tool`, () => { const result = await fetchInbox.execute( { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, onlyUnread: false }, - mockTwistApi, + mockCommsApi, ) expect(extractTextContent(result)).toMatchSnapshot() @@ -377,7 +317,6 @@ describe(`${FETCH_INBOX} tool`, () => { expect(extractTextContent(result)).toContain('DM with Alice, Bob') expect(extractTextContent(result)).toContain('Project Discussion') - // Verify structured content const { structuredContent } = result expect(structuredContent).toEqual( expect.objectContaining({ @@ -390,65 +329,37 @@ describe(`${FETCH_INBOX} tool`, () => { expect(conversations[0].id).toBe(TEST_IDS.CONVERSATION_1) expect(conversations[0].participantNames).toEqual(['Alice', 'Bob']) expect(conversations[0].isUnread).toBe(true) - expect(conversations[0].conversationUrl).toContain('twist.com') + expect(conversations[0].conversationUrl).toContain('comms.todoist.com') expect(conversations[1].title).toBe('Project Discussion') } }) it('should not display conversations when none are unread', async () => { - mockTwistApi.inbox.getInbox.mockResolvedValue([ - { - id: TEST_IDS.THREAD_1, - title: 'Test Thread', - content: 'Thread content', - creator: TEST_IDS.USER_1, - channelId: TEST_IDS.CHANNEL_1, - workspaceId: TEST_IDS.WORKSPACE_1, - commentCount: 0, - lastUpdated: new Date(), - posted: new Date(), - snippet: 'Thread snippet', - snippetCreator: TEST_IDS.USER_1, - starred: false, - pinned: false, - isArchived: false, - inInbox: true, - closed: false, - url: `https://twist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_1}/`, - }, - ]) - mockTwistApi.inbox.getCount.mockResolvedValue(1) - mockTwistApi.threads.getUnread.mockResolvedValue([ - { - threadId: TEST_IDS.THREAD_1, - channelId: TEST_IDS.CHANNEL_1, - objIndex: 1, - directMention: false, - }, - ]) - mockTwistApi.conversations.getUnread.mockResolvedValue([]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: TEST_IDS.CHANNEL_1, - name: 'Test Channel', - workspaceId: TEST_IDS.WORKSPACE_1, - created: new Date(), - archived: false, - public: true, - color: 0, - creator: TEST_IDS.USER_1, + mockCommsApi.inbox.getInbox.mockResolvedValue([makeInboxThread()]) + mockCommsApi.inbox.getCount.mockResolvedValue(1) + mockCommsApi.threads.getUnread.mockResolvedValue({ + data: [ + { + threadId: TEST_IDS.THREAD_1, + channelId: TEST_IDS.CHANNEL_1, + objIndex: 1, + directMention: false, + }, + ], version: 1, }) + mockCommsApi.conversations.getUnread.mockResolvedValue({ data: [], version: 1 }) + mockCommsApi.channels.getChannel.mockResolvedValue(makeChannel()) const result = await fetchInbox.execute( { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, onlyUnread: false }, - mockTwistApi, + mockCommsApi, ) expect(extractTextContent(result)).toMatchSnapshot() expect(extractTextContent(result)).not.toContain('## Conversations') expect(extractTextContent(result)).not.toContain('Total Conversations') - // Verify structured content const { structuredContent } = result expect(structuredContent?.totalConversations).toBe(0) expect(structuredContent?.conversations).toHaveLength(0) @@ -461,48 +372,31 @@ describe(`${FETCH_INBOX} tool`, () => { creator, isArchived, }: { - id: number + id: string title: string creator: number isArchived: boolean }) { - return { + return makeInboxThread({ id, title, content: `${title} content`, creator, - channelId: TEST_IDS.CHANNEL_1, - workspaceId: TEST_IDS.WORKSPACE_1, - commentCount: 1, - lastUpdated: new Date(), - posted: new Date(), snippet: `${title} snippet`, snippetCreator: creator, - starred: false, - pinned: false, isArchived, - inInbox: true, closed: isArchived, - url: `https://twist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${id}/`, - } + commentCount: 1, + url: `https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${id}/`, + }) } function mockArchiveFilterInbox(threads: Array>) { - mockTwistApi.inbox.getInbox.mockResolvedValue(threads) - mockTwistApi.inbox.getCount.mockResolvedValue(0) - mockTwistApi.threads.getUnread.mockResolvedValue([]) - mockTwistApi.conversations.getUnread.mockResolvedValue([]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: TEST_IDS.CHANNEL_1, - name: 'Test Channel', - workspaceId: TEST_IDS.WORKSPACE_1, - created: new Date(), - archived: false, - public: true, - color: 0, - creator: TEST_IDS.USER_1, - version: 1, - }) + mockCommsApi.inbox.getInbox.mockResolvedValue(threads) + mockCommsApi.inbox.getCount.mockResolvedValue(0) + mockCommsApi.threads.getUnread.mockResolvedValue({ data: [], version: 1 }) + mockCommsApi.conversations.getUnread.mockResolvedValue({ data: [], version: 1 }) + mockCommsApi.channels.getChannel.mockResolvedValue(makeChannel()) } it('should default to active threads', async () => { @@ -517,12 +411,11 @@ describe(`${FETCH_INBOX} tool`, () => { const result = await fetchInbox.execute( { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, onlyUnread: false }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.inbox.getInbox).toHaveBeenCalledWith( + expect(mockCommsApi.inbox.getInbox).toHaveBeenCalledWith( expect.objectContaining({ archiveFilter: 'active' }), - { batch: true }, ) const textContent = extractTextContent(result) expect(textContent).toContain('Active Thread') @@ -553,12 +446,11 @@ describe(`${FETCH_INBOX} tool`, () => { onlyUnread: false, archiveFilter: 'archived', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.inbox.getInbox).toHaveBeenCalledWith( + expect(mockCommsApi.inbox.getInbox).toHaveBeenCalledWith( expect.objectContaining({ archiveFilter: 'archived' }), - { batch: true }, ) const textContent = extractTextContent(result) expect(textContent).toContain('Archived Thread [archived]') @@ -594,12 +486,11 @@ describe(`${FETCH_INBOX} tool`, () => { onlyUnread: false, archiveFilter: 'all', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.inbox.getInbox).toHaveBeenCalledWith( + expect(mockCommsApi.inbox.getInbox).toHaveBeenCalledWith( expect.objectContaining({ archiveFilter: 'all' }), - { batch: true }, ) const textContent = extractTextContent(result) expect(textContent).toContain('Active Thread') @@ -622,53 +513,28 @@ describe(`${FETCH_INBOX} tool`, () => { }) describe('missing URL fallback', () => { - it('should construct threadUrl via getFullTwistURL when SDK omits url field', async () => { - mockTwistApi.inbox.getInbox.mockResolvedValue([ - { - id: TEST_IDS.THREAD_1, - title: 'Thread Without URL', - content: 'Content', - creator: TEST_IDS.USER_1, - channelId: TEST_IDS.CHANNEL_1, - workspaceId: TEST_IDS.WORKSPACE_1, - commentCount: 0, - lastUpdated: new Date(), - posted: new Date(), - snippet: 'Snippet', - snippetCreator: TEST_IDS.USER_1, - starred: false, - pinned: false, - isArchived: false, - inInbox: true, - closed: false, - // url intentionally omitted to simulate batch-builder validation failure - }, - ]) - mockTwistApi.inbox.getCount.mockResolvedValue(1) - mockTwistApi.threads.getUnread.mockResolvedValue([ - { - threadId: TEST_IDS.THREAD_1, - channelId: TEST_IDS.CHANNEL_1, - objIndex: 1, - directMention: false, - }, + it('should construct threadUrl via getFullCommsURL when SDK omits url field', async () => { + mockCommsApi.inbox.getInbox.mockResolvedValue([ + makeInboxThread({ title: 'Thread Without URL', url: undefined }), ]) - mockTwistApi.conversations.getUnread.mockResolvedValue([]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: TEST_IDS.CHANNEL_1, - name: 'Test Channel', - workspaceId: TEST_IDS.WORKSPACE_1, - created: new Date(), - archived: false, - public: true, - color: 0, - creator: TEST_IDS.USER_1, + mockCommsApi.inbox.getCount.mockResolvedValue(1) + mockCommsApi.threads.getUnread.mockResolvedValue({ + data: [ + { + threadId: TEST_IDS.THREAD_1, + channelId: TEST_IDS.CHANNEL_1, + objIndex: 1, + directMention: false, + }, + ], version: 1, }) + mockCommsApi.conversations.getUnread.mockResolvedValue({ data: [], version: 1 }) + mockCommsApi.channels.getChannel.mockResolvedValue(makeChannel()) const result = await fetchInbox.execute( { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, onlyUnread: false }, - mockTwistApi, + mockCommsApi, ) const { structuredContent } = result @@ -676,22 +542,25 @@ describe(`${FETCH_INBOX} tool`, () => { const threadUrl = structuredContent?.threads?.[0]?.threadUrl expect(threadUrl).toBeDefined() expect(typeof threadUrl).toBe('string') - expect(threadUrl).toContain('twist.com') + expect(threadUrl).toContain('comms.todoist.com') expect(threadUrl).toContain(String(TEST_IDS.THREAD_1)) }) - it('should construct conversationUrl via getFullTwistURL when SDK omits url field', async () => { - mockTwistApi.inbox.getInbox.mockResolvedValue([]) - mockTwistApi.inbox.getCount.mockResolvedValue(0) - mockTwistApi.threads.getUnread.mockResolvedValue([]) - mockTwistApi.conversations.getUnread.mockResolvedValue([ - { - conversationId: TEST_IDS.CONVERSATION_1, - objIndex: 5, - directMention: false, - }, - ]) - mockTwistApi.conversations.getConversation.mockResolvedValue({ + it('should construct conversationUrl via getFullCommsURL when SDK omits url field', async () => { + mockCommsApi.inbox.getInbox.mockResolvedValue([]) + mockCommsApi.inbox.getCount.mockResolvedValue(0) + mockCommsApi.threads.getUnread.mockResolvedValue({ data: [], version: 1 }) + mockCommsApi.conversations.getUnread.mockResolvedValue({ + data: [ + { + conversationId: TEST_IDS.CONVERSATION_1, + objIndex: 5, + directMention: false, + }, + ], + version: 1, + }) + mockCommsApi.conversations.getConversation.mockResolvedValue({ id: TEST_IDS.CONVERSATION_1, workspaceId: TEST_IDS.WORKSPACE_1, userIds: [TEST_IDS.USER_1, TEST_IDS.USER_2], @@ -705,28 +574,26 @@ describe(`${FETCH_INBOX} tool`, () => { creator: TEST_IDS.USER_1, // url intentionally omitted } as never) - mockTwistApi.workspaceUsers.getUserById.mockImplementation( + mockCommsApi.workspaceUsers.getUserById.mockImplementation( (args: { workspaceId: number; userId: number }) => { if (args.userId === TEST_IDS.USER_1) { return Promise.resolve({ id: TEST_IDS.USER_1, - name: 'Alice', + fullName: 'Alice', shortName: 'Alice', - bot: false, timezone: 'UTC', removed: false, - userType: 'MEMBER' as const, + userType: 'USER' as const, version: 1, }) as never } return Promise.resolve({ id: TEST_IDS.USER_2, - name: 'Bob', + fullName: 'Bob', shortName: 'Bob', - bot: false, timezone: 'UTC', removed: false, - userType: 'MEMBER' as const, + userType: 'USER' as const, version: 1, }) as never }, @@ -734,7 +601,7 @@ describe(`${FETCH_INBOX} tool`, () => { const result = await fetchInbox.execute( { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, onlyUnread: false }, - mockTwistApi, + mockCommsApi, ) const { structuredContent } = result @@ -742,7 +609,7 @@ describe(`${FETCH_INBOX} tool`, () => { const conversationUrl = structuredContent?.conversations?.[0]?.conversationUrl expect(conversationUrl).toBeDefined() expect(typeof conversationUrl).toBe('string') - expect(conversationUrl).toContain('twist.com') + expect(conversationUrl).toContain('comms.todoist.com') expect(conversationUrl).toContain(String(TEST_IDS.CONVERSATION_1)) }) }) @@ -750,12 +617,12 @@ describe(`${FETCH_INBOX} tool`, () => { describe('error handling', () => { it('should propagate API errors', async () => { const apiError = new Error('API Error: Unauthorized') - mockTwistApi.inbox.getInbox.mockRejectedValue(apiError) + mockCommsApi.inbox.getInbox.mockRejectedValue(apiError) await expect( fetchInbox.execute( { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, onlyUnread: false }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('API Error: Unauthorized') }) diff --git a/src/tools/__tests__/get-groups.test.ts b/src/tools/__tests__/get-groups.test.ts index f5916be..b2ac2e4 100644 --- a/src/tools/__tests__/get-groups.test.ts +++ b/src/tools/__tests__/get-groups.test.ts @@ -1,4 +1,4 @@ -import type { Group, TwistApi } from '@doist/twist-sdk' +import type { Group, CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { extractStructuredContent, @@ -9,18 +9,17 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { getGroups } from '../get-groups.js' -const mockTwistApi = { +const mockCommsApi = { groups: { getGroups: jest.fn(), getGroup: jest.fn(), }, - batch: jest.fn(), -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { GET_GROUPS } = ToolNames const createMockGroup = (overrides: Partial = {}): Group => ({ - id: 100, + id: TEST_IDS.GROUP_1, name: 'Product Automation', description: 'Automation recipients', workspaceId: TEST_IDS.WORKSPACE_1, @@ -32,14 +31,6 @@ const createMockGroup = (overrides: Partial = {}): Group => ({ describe(`${GET_GROUPS} tool`, () => { beforeEach(() => { jest.clearAllMocks() - mockTwistApi.batch.mockImplementation(async (...args: readonly unknown[]) => { - const results = [] - for (const arg of args) { - const result = await arg - results.push({ data: result }) - } - return results as never - }) }) describe('fetching groups', () => { @@ -47,22 +38,22 @@ describe(`${GET_GROUPS} tool`, () => { const mockGroups = [ createMockGroup(), createMockGroup({ - id: 200, + id: TEST_IDS.GROUP_2, name: 'Engineering', description: null, userIds: [TEST_IDS.USER_3], }), ] - mockTwistApi.groups.getGroups.mockResolvedValue(mockGroups) + mockCommsApi.groups.getGroups.mockResolvedValue(mockGroups) const result = await getGroups.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.groups.getGroups).toHaveBeenCalledWith(TEST_IDS.WORKSPACE_1) - expect(mockTwistApi.batch).not.toHaveBeenCalled() + expect(mockCommsApi.groups.getGroups).toHaveBeenCalledWith(TEST_IDS.WORKSPACE_1) + expect(mockCommsApi.groups.getGroup).not.toHaveBeenCalled() const textContent = extractTextContent(result) expect(textContent).toContain(`**Workspace ID:** ${TEST_IDS.WORKSPACE_1}`) @@ -80,12 +71,12 @@ describe(`${GET_GROUPS} tool`, () => { filteredGroups: 2, groups: expect.arrayContaining([ expect.objectContaining({ - id: 100, + id: TEST_IDS.GROUP_1, name: 'Product Automation', memberCount: 2, }), expect.objectContaining({ - id: 200, + id: TEST_IDS.GROUP_2, name: 'Engineering', memberCount: 1, }), @@ -97,11 +88,11 @@ describe(`${GET_GROUPS} tool`, () => { }) it('should handle empty groupIds array by fetching all groups', async () => { - mockTwistApi.groups.getGroups.mockResolvedValue([createMockGroup()]) + mockCommsApi.groups.getGroups.mockResolvedValue([createMockGroup()]) const result = await getGroups.execute( { workspaceId: TEST_IDS.WORKSPACE_1, groupIds: [] }, - mockTwistApi, + mockCommsApi, ) const structuredContent = extractStructuredContent(result) @@ -110,36 +101,38 @@ describe(`${GET_GROUPS} tool`, () => { }) describe('filtering groups', () => { - it('should filter groups by ID', async () => { - mockTwistApi.groups.getGroup.mockImplementation(async (id: number) => { - if (id === 100) { - return createMockGroup({ id: 100, name: 'Product Automation' }) - } - if (id === 300) { - return createMockGroup({ id: 300, name: 'Marketing' }) - } - throw new Error('Group not found') - }) + it('should fetch specific groups by ID', async () => { + const otherGroupId = 'group-id-marketing' + mockCommsApi.groups.getGroup.mockImplementation( + async (args: { id: string; workspaceId: number }) => { + if (args.id === TEST_IDS.GROUP_1) { + return createMockGroup({ + id: TEST_IDS.GROUP_1, + name: 'Product Automation', + }) + } + if (args.id === otherGroupId) { + return createMockGroup({ id: otherGroupId, name: 'Marketing' }) + } + throw new Error('Group not found') + }, + ) const result = await getGroups.execute( - { workspaceId: TEST_IDS.WORKSPACE_1, groupIds: [100, 300] }, - mockTwistApi, + { + workspaceId: TEST_IDS.WORKSPACE_1, + groupIds: [TEST_IDS.GROUP_1, otherGroupId], + }, + mockCommsApi, ) - expect(mockTwistApi.groups.getGroups).not.toHaveBeenCalled() - expect(mockTwistApi.groups.getGroup).toHaveBeenNthCalledWith(1, 100, { - batch: true, - }) - expect(mockTwistApi.groups.getGroup).toHaveBeenNthCalledWith(2, 300, { - batch: true, - }) - expect(mockTwistApi.batch).toHaveBeenCalledTimes(1) + expect(mockCommsApi.groups.getGroups).not.toHaveBeenCalled() + expect(mockCommsApi.groups.getGroup).toHaveBeenCalledTimes(2) const textContent = extractTextContent(result) expect(textContent).toContain('**Total Groups:** 2') expect(textContent).toContain('## Product Automation') expect(textContent).toContain('## Marketing') - expect(textContent).not.toContain('## Engineering') const structuredContent = extractStructuredContent(result) expect(structuredContent.totalGroups).toBe(2) @@ -148,16 +141,16 @@ describe(`${GET_GROUPS} tool`, () => { it('should filter groups by name search case-insensitively', async () => { const mockGroups = [ - createMockGroup({ id: 100, name: 'Product Automation' }), - createMockGroup({ id: 200, name: 'Engineering' }), - createMockGroup({ id: 300, name: 'Automation QA' }), + createMockGroup({ id: TEST_IDS.GROUP_1, name: 'Product Automation' }), + createMockGroup({ id: TEST_IDS.GROUP_2, name: 'Engineering' }), + createMockGroup({ id: 'group-id-3', name: 'Automation QA' }), ] - mockTwistApi.groups.getGroups.mockResolvedValue(mockGroups) + mockCommsApi.groups.getGroups.mockResolvedValue(mockGroups) const result = await getGroups.execute( { workspaceId: TEST_IDS.WORKSPACE_1, searchText: 'AUTOMATION' }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -173,28 +166,36 @@ describe(`${GET_GROUPS} tool`, () => { }) it('should combine ID and search filters', async () => { - mockTwistApi.groups.getGroup.mockImplementation(async (id: number) => { - if (id === 100) { - return createMockGroup({ id: 100, name: 'Product Automation' }) - } - if (id === 300) { - return createMockGroup({ id: 300, name: 'Marketing' }) - } - throw new Error('Group not found') - }) + const otherGroupId = 'group-id-marketing' + mockCommsApi.groups.getGroup.mockImplementation( + async (args: { id: string; workspaceId: number }) => { + if (args.id === TEST_IDS.GROUP_1) { + return createMockGroup({ + id: TEST_IDS.GROUP_1, + name: 'Product Automation', + }) + } + if (args.id === otherGroupId) { + return createMockGroup({ id: otherGroupId, name: 'Marketing' }) + } + throw new Error('Group not found') + }, + ) const result = await getGroups.execute( - { workspaceId: TEST_IDS.WORKSPACE_1, groupIds: [100, 300], searchText: 'auto' }, - mockTwistApi, + { + workspaceId: TEST_IDS.WORKSPACE_1, + groupIds: [TEST_IDS.GROUP_1, otherGroupId], + searchText: 'auto', + }, + mockCommsApi, ) - expect(mockTwistApi.groups.getGroups).not.toHaveBeenCalled() - expect(mockTwistApi.batch).toHaveBeenCalledTimes(1) + expect(mockCommsApi.groups.getGroups).not.toHaveBeenCalled() const textContent = extractTextContent(result) expect(textContent).toContain('**Total Groups:** 2 (1 matching search)') expect(textContent).toContain('## Product Automation') - expect(textContent).not.toContain('## Engineering Automation') expect(textContent).not.toContain('## Marketing') const structuredContent = extractStructuredContent(result) @@ -204,11 +205,11 @@ describe(`${GET_GROUPS} tool`, () => { }) it('should handle no matching groups', async () => { - mockTwistApi.groups.getGroups.mockResolvedValue([createMockGroup()]) + mockCommsApi.groups.getGroups.mockResolvedValue([createMockGroup()]) const result = await getGroups.execute( { workspaceId: TEST_IDS.WORKSPACE_1, searchText: 'nonexistent' }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -224,11 +225,11 @@ describe(`${GET_GROUPS} tool`, () => { describe('edge cases', () => { it('should handle empty group list', async () => { - mockTwistApi.groups.getGroups.mockResolvedValue([]) + mockCommsApi.groups.getGroups.mockResolvedValue([]) const result = await getGroups.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -243,10 +244,10 @@ describe(`${GET_GROUPS} tool`, () => { describe('error handling', () => { it('should propagate API errors', async () => { const apiError = new Error(TEST_ERRORS.API_UNAUTHORIZED) - mockTwistApi.groups.getGroups.mockRejectedValue(apiError) + mockCommsApi.groups.getGroups.mockRejectedValue(apiError) await expect( - getGroups.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockTwistApi), + getGroups.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockCommsApi), ).rejects.toThrow(TEST_ERRORS.API_UNAUTHORIZED) }) }) diff --git a/src/tools/__tests__/get-mentions.test.ts b/src/tools/__tests__/get-mentions.test.ts index 2af4d88..aab145e 100644 --- a/src/tools/__tests__/get-mentions.test.ts +++ b/src/tools/__tests__/get-mentions.test.ts @@ -1,10 +1,10 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { extractTextContent, TEST_IDS } from '../../utils/test-helpers.js' import { ToolNames } from '../../utils/tool-names.js' import { getMentions } from '../get-mentions.js' -const mockTwistApi = { +const mockCommsApi = { batch: jest.fn(), search: { search: jest.fn(), @@ -15,14 +15,14 @@ const mockTwistApi = { workspaceUsers: { getUserById: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { GET_MENTIONS } = ToolNames describe(`${GET_MENTIONS} tool`, () => { beforeEach(() => { jest.clearAllMocks() - mockTwistApi.batch.mockImplementation(async (...args: readonly unknown[]) => { + mockCommsApi.batch.mockImplementation(async (...args: readonly unknown[]) => { const results = [] for (const arg of args) { const result = await arg @@ -33,7 +33,7 @@ describe(`${GET_MENTIONS} tool`, () => { }) it('calls search.search with mentionSelf=true and no query', async () => { - mockTwistApi.search.search.mockResolvedValue({ + mockCommsApi.search.search.mockResolvedValue({ items: [], hasMore: false, isPlanRestricted: false, @@ -44,10 +44,10 @@ describe(`${GET_MENTIONS} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, }, - mockTwistApi, + mockCommsApi, ) - const call = mockTwistApi.search.search.mock.calls[0]?.[0] as Record + const call = mockCommsApi.search.search.mock.calls[0]?.[0] as Record expect(call).toBeDefined() expect(call.mentionSelf).toBe(true) expect(call.workspaceId).toBe(TEST_IDS.WORKSPACE_1) @@ -56,7 +56,7 @@ describe(`${GET_MENTIONS} tool`, () => { }) it('forwards filters to search.search', async () => { - mockTwistApi.search.search.mockResolvedValue({ + mockCommsApi.search.search.mockResolvedValue({ items: [], hasMore: false, isPlanRestricted: false, @@ -72,10 +72,10 @@ describe(`${GET_MENTIONS} tool`, () => { limit: 25, cursor: 'cursor-abc', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.search.search).toHaveBeenCalledWith({ + expect(mockCommsApi.search.search).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, mentionSelf: true, channelIds: [TEST_IDS.CHANNEL_1], @@ -88,7 +88,7 @@ describe(`${GET_MENTIONS} tool`, () => { }) it('returns structured mentions_results with enriched names and URLs', async () => { - mockTwistApi.search.search.mockResolvedValue({ + mockCommsApi.search.search.mockResolvedValue({ items: [ { id: 'thread-123', @@ -105,18 +105,17 @@ describe(`${GET_MENTIONS} tool`, () => { hasMore: false, isPlanRestricted: false, }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ id: TEST_IDS.USER_1, - name: 'Test User 1', + fullName: 'Test User 1', shortName: 'TU1', email: 'user1@test.com', userType: 'USER' as const, - bot: false, removed: false, timezone: 'UTC', version: 1, - }) - mockTwistApi.channels.getChannel.mockResolvedValue({ + } as never) + mockCommsApi.channels.getChannel.mockResolvedValue({ id: TEST_IDS.CHANNEL_1, name: 'Test Channel', workspaceId: TEST_IDS.WORKSPACE_1, @@ -133,7 +132,7 @@ describe(`${GET_MENTIONS} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, }, - mockTwistApi, + mockCommsApi, ) const { structuredContent } = result @@ -158,7 +157,7 @@ describe(`${GET_MENTIONS} tool`, () => { }) it('exposes pagination cursor when hasMore is true', async () => { - mockTwistApi.search.search.mockResolvedValue({ + mockCommsApi.search.search.mockResolvedValue({ items: [ { id: 'comment-1', @@ -175,18 +174,17 @@ describe(`${GET_MENTIONS} tool`, () => { nextCursorMark: 'next-cursor-xyz', isPlanRestricted: false, }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ id: TEST_IDS.USER_1, - name: 'Test User 1', + fullName: 'Test User 1', shortName: 'TU1', email: 'user1@test.com', userType: 'USER' as const, - bot: false, removed: false, timezone: 'UTC', version: 1, - }) - mockTwistApi.channels.getChannel.mockResolvedValue({ + } as never) + mockCommsApi.channels.getChannel.mockResolvedValue({ id: TEST_IDS.CHANNEL_1, name: 'Test Channel', workspaceId: TEST_IDS.WORKSPACE_1, @@ -203,7 +201,7 @@ describe(`${GET_MENTIONS} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, limit: 1, }, - mockTwistApi, + mockCommsApi, ) expect(result.structuredContent?.hasMore).toBe(true) @@ -212,7 +210,7 @@ describe(`${GET_MENTIONS} tool`, () => { }) it('handles no results found', async () => { - mockTwistApi.search.search.mockResolvedValue({ + mockCommsApi.search.search.mockResolvedValue({ items: [], hasMore: false, isPlanRestricted: false, @@ -223,7 +221,7 @@ describe(`${GET_MENTIONS} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, }, - mockTwistApi, + mockCommsApi, ) expect(extractTextContent(result)).toContain('No mentions found') @@ -231,7 +229,7 @@ describe(`${GET_MENTIONS} tool`, () => { }) it('propagates API errors', async () => { - mockTwistApi.search.search.mockRejectedValue(new Error('Search API error')) + mockCommsApi.search.search.mockRejectedValue(new Error('Search API error')) await expect( getMentions.execute( @@ -239,7 +237,7 @@ describe(`${GET_MENTIONS} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Search API error') }) diff --git a/src/tools/__tests__/get-users.test.ts b/src/tools/__tests__/get-users.test.ts index 9d2441a..0d411cc 100644 --- a/src/tools/__tests__/get-users.test.ts +++ b/src/tools/__tests__/get-users.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { extractStructuredContent, @@ -9,23 +9,21 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { getUsers } from '../get-users.js' -// Mock the Twist API -const mockTwistApi = { +// Mock the Comms API +const mockCommsApi = { workspaceUsers: { getWorkspaceUsers: jest.fn(), getUserById: jest.fn(), }, - batch: jest.fn(), -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { GET_USERS } = ToolNames -const createMockWorkspaceUser = (overrides = {}) => ({ +const createMockWorkspaceUser = (overrides: Partial> = {}) => ({ id: TEST_IDS.USER_1, - name: 'Alice Johnson', + fullName: 'Alice Johnson', shortName: 'Alice', email: 'alice@example.com', - bot: false, removed: false, timezone: 'America/New_York', userType: 'USER' as const, @@ -36,15 +34,6 @@ const createMockWorkspaceUser = (overrides = {}) => ({ describe(`${GET_USERS} tool`, () => { beforeEach(() => { jest.clearAllMocks() - // Mock batch to return responses with .data property - mockTwistApi.batch.mockImplementation(async (...args: readonly unknown[]) => { - const results = [] - for (const arg of args) { - const result = await arg - results.push({ data: result }) - } - return results as never - }) }) describe('fetching all users', () => { @@ -53,28 +42,27 @@ describe(`${GET_USERS} tool`, () => { createMockWorkspaceUser(), createMockWorkspaceUser({ id: TEST_IDS.USER_2, - name: 'Bob Smith', + fullName: 'Bob Smith', shortName: 'Bob', email: 'bob@example.com', userType: 'ADMIN', }), createMockWorkspaceUser({ id: TEST_IDS.USER_3, - name: 'Charlie Bot', + fullName: 'Charlie Person', shortName: 'Charlie', - email: 'bot@example.com', - bot: true, + email: 'charlie@example.com', }), ] - mockTwistApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers) + mockCommsApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers as never) const result = await getUsers.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.workspaceUsers.getWorkspaceUsers).toHaveBeenCalledWith({ + expect(mockCommsApi.workspaceUsers.getWorkspaceUsers).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, }) @@ -83,7 +71,7 @@ describe(`${GET_USERS} tool`, () => { expect(textContent).toContain('**Total Users:** 3') expect(textContent).toContain('## Alice Johnson') expect(textContent).toContain('## Bob Smith') - expect(textContent).toContain('## Charlie Bot 🤖') + expect(textContent).toContain('## Charlie Person') const structuredContent = extractStructuredContent(result) expect(structuredContent).toEqual({ @@ -105,8 +93,7 @@ describe(`${GET_USERS} tool`, () => { }), expect.objectContaining({ id: TEST_IDS.USER_3, - name: 'Charlie Bot', - bot: true, + name: 'Charlie Person', }), ]), }) @@ -115,14 +102,14 @@ describe(`${GET_USERS} tool`, () => { it('should handle empty userIds array (fetch all)', async () => { const mockUsers = [createMockWorkspaceUser()] - mockTwistApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers) + mockCommsApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers as never) const result = await getUsers.execute( { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [] }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.workspaceUsers.getWorkspaceUsers).toHaveBeenCalledWith({ + expect(mockCommsApi.workspaceUsers.getWorkspaceUsers).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, }) @@ -132,19 +119,19 @@ describe(`${GET_USERS} tool`, () => { }) describe('fetching specific users', () => { - it('should batch fetch specific users by ID', async () => { - mockTwistApi.workspaceUsers.getUserById.mockImplementation( + it('should fetch specific users by ID in parallel', async () => { + mockCommsApi.workspaceUsers.getUserById.mockImplementation( async (args: { workspaceId: number; userId: number }) => { if (args.userId === TEST_IDS.USER_1) { - return createMockWorkspaceUser() + return createMockWorkspaceUser() as never } if (args.userId === TEST_IDS.USER_2) { return createMockWorkspaceUser({ id: TEST_IDS.USER_2, - name: 'Bob Smith', + fullName: 'Bob Smith', shortName: 'Bob', email: 'bob@example.com', - }) + }) as never } throw new Error('User not found') }, @@ -155,11 +142,11 @@ describe(`${GET_USERS} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [TEST_IDS.USER_1, TEST_IDS.USER_2], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.batch).toHaveBeenCalled() - expect(mockTwistApi.workspaceUsers.getWorkspaceUsers).not.toHaveBeenCalled() + expect(mockCommsApi.workspaceUsers.getUserById).toHaveBeenCalledTimes(2) + expect(mockCommsApi.workspaceUsers.getWorkspaceUsers).not.toHaveBeenCalled() const textContent = extractTextContent(result) expect(textContent).toContain('**Total Users:** 2') @@ -172,14 +159,16 @@ describe(`${GET_USERS} tool`, () => { }) it('should handle single user ID', async () => { - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue(createMockWorkspaceUser()) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue( + createMockWorkspaceUser() as never, + ) const result = await getUsers.execute( { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [TEST_IDS.USER_1] }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.batch).toHaveBeenCalled() + expect(mockCommsApi.workspaceUsers.getUserById).toHaveBeenCalledTimes(1) const structuredContent = extractStructuredContent(result) expect(structuredContent.users).toHaveLength(1) @@ -190,24 +179,24 @@ describe(`${GET_USERS} tool`, () => { describe('search filtering', () => { it('should filter users by name (case-insensitive)', async () => { const mockUsers = [ - createMockWorkspaceUser({ name: 'Alice Johnson' }), + createMockWorkspaceUser({ fullName: 'Alice Johnson' }), createMockWorkspaceUser({ id: TEST_IDS.USER_2, - name: 'Bob Smith', + fullName: 'Bob Smith', email: 'bob@example.com', }), createMockWorkspaceUser({ id: TEST_IDS.USER_3, - name: 'Alice Cooper', + fullName: 'Alice Cooper', email: 'alice2@example.com', }), ] - mockTwistApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers) + mockCommsApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers as never) const result = await getUsers.execute( { workspaceId: TEST_IDS.WORKSPACE_1, searchText: 'alice' }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -224,19 +213,19 @@ describe(`${GET_USERS} tool`, () => { it('should filter users by email (case-insensitive)', async () => { const mockUsers = [ - createMockWorkspaceUser({ name: 'Alice', email: 'alice@company.com' }), + createMockWorkspaceUser({ fullName: 'Alice', email: 'alice@company.com' }), createMockWorkspaceUser({ id: TEST_IDS.USER_2, - name: 'Bob', + fullName: 'Bob', email: 'bob@different.com', }), ] - mockTwistApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers) + mockCommsApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers as never) const result = await getUsers.execute( { workspaceId: TEST_IDS.WORKSPACE_1, searchText: 'COMPANY' }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -248,11 +237,11 @@ describe(`${GET_USERS} tool`, () => { it('should handle no search matches', async () => { const mockUsers = [createMockWorkspaceUser()] - mockTwistApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers) + mockCommsApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers as never) const result = await getUsers.execute( { workspaceId: TEST_IDS.WORKSPACE_1, searchText: 'nonexistent' }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -272,17 +261,17 @@ describe(`${GET_USERS} tool`, () => { createMockWorkspaceUser({ userType: 'ADMIN', removed: false }), createMockWorkspaceUser({ id: TEST_IDS.USER_2, - name: 'Guest User', + fullName: 'Guest User', userType: 'GUEST', removed: true, }), ] - mockTwistApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers) + mockCommsApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers as never) const result = await getUsers.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -295,11 +284,11 @@ describe(`${GET_USERS} tool`, () => { it('should handle users without email', async () => { const mockUsers = [createMockWorkspaceUser({ email: undefined })] - mockTwistApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers) + mockCommsApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue(mockUsers as never) const result = await getUsers.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -313,21 +302,21 @@ describe(`${GET_USERS} tool`, () => { describe('error handling', () => { it('should propagate API errors', async () => { const apiError = new Error(TEST_ERRORS.API_UNAUTHORIZED) - mockTwistApi.workspaceUsers.getWorkspaceUsers.mockRejectedValue(apiError) + mockCommsApi.workspaceUsers.getWorkspaceUsers.mockRejectedValue(apiError) await expect( - getUsers.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockTwistApi), + getUsers.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockCommsApi), ).rejects.toThrow(TEST_ERRORS.API_UNAUTHORIZED) }) }) describe('edge cases', () => { it('should handle empty user list', async () => { - mockTwistApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue([]) + mockCommsApi.workspaceUsers.getWorkspaceUsers.mockResolvedValue([]) const result = await getUsers.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) diff --git a/src/tools/__tests__/get-workspaces.test.ts b/src/tools/__tests__/get-workspaces.test.ts index e5c73d2..84f3135 100644 --- a/src/tools/__tests__/get-workspaces.test.ts +++ b/src/tools/__tests__/get-workspaces.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { createMockWorkspace, @@ -10,37 +10,48 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { getWorkspaces } from '../get-workspaces.js' -// Mock the Twist API -const mockTwistApi = { +// Mock the Comms API +const mockCommsApi = { workspaces: { getWorkspaces: jest.fn(), }, - channels: { - getChannel: jest.fn(), - }, conversations: { getConversation: jest.fn(), }, workspaceUsers: { getUserById: jest.fn(), }, - batch: jest.fn(), -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { GET_WORKSPACES } = ToolNames +const mockCreator = { + id: TEST_IDS.USER_1, + fullName: 'Test User', + shortName: 'TU', + removed: false, + timezone: 'UTC', + userType: 'USER' as const, + version: 1, +} + +const mockConversation = { + id: TEST_IDS.CONVERSATION_1, + workspaceId: TEST_IDS.WORKSPACE_1, + userIds: [TEST_IDS.USER_1, TEST_IDS.USER_2], + title: 'Team Discussion', + lastObjIndex: 0, + snippet: '', + snippetCreators: [], + archived: false, + creator: TEST_IDS.USER_1, + created: new Date(), + lastActive: new Date(), +} + describe(`${GET_WORKSPACES} tool`, () => { beforeEach(() => { jest.clearAllMocks() - // Mock batch to return responses with .data property - mockTwistApi.batch.mockImplementation(async (...args: readonly unknown[]) => { - const results = [] - for (const arg of args) { - const result = await arg - results.push({ data: result }) - } - return results as never - }) }) it('should generate workspaces list with all required fields', async () => { @@ -51,47 +62,14 @@ describe(`${GET_WORKSPACES} tool`, () => { plan: 'unlimited', }) - mockTwistApi.workspaces.getWorkspaces.mockResolvedValue([mockWorkspace1, mockWorkspace2]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: TEST_IDS.CHANNEL_1, - name: 'General', - workspaceId: TEST_IDS.WORKSPACE_1, - creator: TEST_IDS.USER_1, - public: true, - archived: false, - created: new Date(), - version: 1, - }) - mockTwistApi.conversations.getConversation.mockResolvedValue({ - id: TEST_IDS.CONVERSATION_1, - workspaceId: TEST_IDS.WORKSPACE_1, - userIds: [TEST_IDS.USER_1, TEST_IDS.USER_2], - title: 'Team Discussion', - lastObjIndex: 0, - snippet: '', - snippetCreators: [], - archived: false, - creator: TEST_IDS.USER_1, - created: new Date(), - lastActive: new Date(), - }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User', - shortName: 'TU', - bot: false, - removed: false, - timezone: 'UTC', - userType: 'USER' as const, - version: 1, - }) + mockCommsApi.workspaces.getWorkspaces.mockResolvedValue([mockWorkspace1, mockWorkspace2]) + mockCommsApi.conversations.getConversation.mockResolvedValue(mockConversation as never) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue(mockCreator as never) - const result = await getWorkspaces.execute({}, mockTwistApi) + const result = await getWorkspaces.execute({}, mockCommsApi) - expect(mockTwistApi.workspaces.getWorkspaces).toHaveBeenCalledWith() - expect(mockTwistApi.batch).toHaveBeenCalled() + expect(mockCommsApi.workspaces.getWorkspaces).toHaveBeenCalledWith() - // Test text content contains expected information const textContent = extractTextContent(result) expect(textContent).toContain('Found 2 workspaces:') expect(textContent).toContain('Test Workspace') @@ -100,49 +78,39 @@ describe(`${GET_WORKSPACES} tool`, () => { expect(textContent).toContain(`**ID:** ${TEST_IDS.WORKSPACE_2}`) expect(textContent).toContain(`**Creator:** Test User (${TEST_IDS.USER_1})`) expect(textContent).toContain( - `**Default Channel:** [General](https://twist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/) (${TEST_IDS.CHANNEL_1})`, - ) - expect(textContent).toContain( - `**Default Conversation:** [Team Discussion](https://twist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_1}/) (${TEST_IDS.CONVERSATION_1})`, + `**Default Conversation:** [Team Discussion](https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_1}/) (${TEST_IDS.CONVERSATION_1})`, ) expect(textContent).toContain(`**Plan:** free`) expect(textContent).toContain(`**Plan:** unlimited`) - // Test structured content const structuredContent = extractStructuredContent(result) expect(structuredContent).toEqual({ type: 'get_workspaces', workspaces: [ - { + expect.objectContaining({ id: mockWorkspace1.id, name: mockWorkspace1.name, creator: mockWorkspace1.creator, creatorName: 'Test User', created: mockWorkspace1.created.toISOString(), - url: `https://twist.com/a/${mockWorkspace1.id}/`, - defaultChannel: mockWorkspace1.defaultChannel, - defaultChannelName: 'General', - defaultChannelUrl: `https://twist.com/a/${mockWorkspace1.id}/ch/${mockWorkspace1.defaultChannel}/`, + url: `https://comms.todoist.com/a/${mockWorkspace1.id}/`, defaultConversation: mockWorkspace1.defaultConversation, defaultConversationTitle: 'Team Discussion', - defaultConversationUrl: `https://twist.com/a/${mockWorkspace1.id}/msg/${mockWorkspace1.defaultConversation}/`, + defaultConversationUrl: `https://comms.todoist.com/a/${mockWorkspace1.id}/msg/${mockWorkspace1.defaultConversation}/`, plan: mockWorkspace1.plan, - }, - { + }), + expect.objectContaining({ id: mockWorkspace2.id, name: mockWorkspace2.name, creator: mockWorkspace2.creator, creatorName: 'Test User', created: mockWorkspace2.created.toISOString(), - url: `https://twist.com/a/${mockWorkspace2.id}/`, - defaultChannel: mockWorkspace2.defaultChannel, - defaultChannelName: 'General', - defaultChannelUrl: `https://twist.com/a/${mockWorkspace2.id}/ch/${mockWorkspace2.defaultChannel}/`, + url: `https://comms.todoist.com/a/${mockWorkspace2.id}/`, defaultConversation: mockWorkspace2.defaultConversation, defaultConversationTitle: 'Team Discussion', - defaultConversationUrl: `https://twist.com/a/${mockWorkspace2.id}/msg/${mockWorkspace2.defaultConversation}/`, + defaultConversationUrl: `https://comms.todoist.com/a/${mockWorkspace2.id}/msg/${mockWorkspace2.defaultConversation}/`, plan: mockWorkspace2.plan, - }, + }), ], }) }) @@ -150,42 +118,11 @@ describe(`${GET_WORKSPACES} tool`, () => { it('should handle a single workspace', async () => { const mockWorkspace = createMockWorkspace() - mockTwistApi.workspaces.getWorkspaces.mockResolvedValue([mockWorkspace]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: TEST_IDS.CHANNEL_1, - name: 'General', - workspaceId: TEST_IDS.WORKSPACE_1, - creator: TEST_IDS.USER_1, - public: true, - archived: false, - created: new Date(), - version: 1, - }) - mockTwistApi.conversations.getConversation.mockResolvedValue({ - id: TEST_IDS.CONVERSATION_1, - workspaceId: TEST_IDS.WORKSPACE_1, - userIds: [TEST_IDS.USER_1, TEST_IDS.USER_2], - title: 'Team Discussion', - lastObjIndex: 0, - snippet: '', - snippetCreators: [], - archived: false, - creator: TEST_IDS.USER_1, - created: new Date(), - lastActive: new Date(), - }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User', - shortName: 'TU', - bot: false, - removed: false, - timezone: 'UTC', - userType: 'USER' as const, - version: 1, - }) + mockCommsApi.workspaces.getWorkspaces.mockResolvedValue([mockWorkspace]) + mockCommsApi.conversations.getConversation.mockResolvedValue(mockConversation as never) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue(mockCreator as never) - const result = await getWorkspaces.execute({}, mockTwistApi) + const result = await getWorkspaces.execute({}, mockCommsApi) const textContent = extractTextContent(result) expect(textContent).toContain('Found 1 workspace:') @@ -196,9 +133,9 @@ describe(`${GET_WORKSPACES} tool`, () => { }) it('should handle no workspaces', async () => { - mockTwistApi.workspaces.getWorkspaces.mockResolvedValue([]) + mockCommsApi.workspaces.getWorkspaces.mockResolvedValue([]) - const result = await getWorkspaces.execute({}, mockTwistApi) + const result = await getWorkspaces.execute({}, mockCommsApi) const textContent = extractTextContent(result) expect(textContent).toContain('No workspaces found.') @@ -209,118 +146,43 @@ describe(`${GET_WORKSPACES} tool`, () => { it('should handle workspaces without optional fields', async () => { const mockWorkspace = createMockWorkspace({ - defaultChannel: undefined, defaultConversation: undefined, plan: undefined, }) - mockTwistApi.workspaces.getWorkspaces.mockResolvedValue([mockWorkspace]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User', - shortName: 'TU', - bot: false, - removed: false, - timezone: 'UTC', - userType: 'USER' as const, - version: 1, - }) + mockCommsApi.workspaces.getWorkspaces.mockResolvedValue([mockWorkspace]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue(mockCreator as never) - const result = await getWorkspaces.execute({}, mockTwistApi) + const result = await getWorkspaces.execute({}, mockCommsApi) const textContent = extractTextContent(result) - expect(textContent).not.toContain('Default Channel') expect(textContent).not.toContain('Default Conversation') expect(textContent).not.toContain('Plan:') }) - it('should handle workspaces with only default channel', async () => { - const mockWorkspace = createMockWorkspace({ - defaultConversation: undefined, - }) - - mockTwistApi.workspaces.getWorkspaces.mockResolvedValue([mockWorkspace]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: TEST_IDS.CHANNEL_1, - name: 'General', - workspaceId: TEST_IDS.WORKSPACE_1, - creator: TEST_IDS.USER_1, - public: true, - archived: false, - created: new Date(), - version: 1, - }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User', - shortName: 'TU', - bot: false, - removed: false, - timezone: 'UTC', - userType: 'USER' as const, - version: 1, - }) - - const result = await getWorkspaces.execute({}, mockTwistApi) - - const textContent = extractTextContent(result) - expect(textContent).toContain( - `**Default Channel:** [General](https://twist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/) (${TEST_IDS.CHANNEL_1})`, - ) - expect(textContent).not.toContain('Default Conversation') - }) - it('should handle conversations without titles', async () => { const mockWorkspace = createMockWorkspace() - mockTwistApi.workspaces.getWorkspaces.mockResolvedValue([mockWorkspace]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: TEST_IDS.CHANNEL_1, - name: 'General', - workspaceId: TEST_IDS.WORKSPACE_1, - creator: TEST_IDS.USER_1, - public: true, - archived: false, - created: new Date(), - version: 1, - }) - mockTwistApi.conversations.getConversation.mockResolvedValue({ - id: TEST_IDS.CONVERSATION_1, - workspaceId: TEST_IDS.WORKSPACE_1, - userIds: [TEST_IDS.USER_1, TEST_IDS.USER_2], + mockCommsApi.workspaces.getWorkspaces.mockResolvedValue([mockWorkspace]) + mockCommsApi.conversations.getConversation.mockResolvedValue({ + ...mockConversation, title: null, - lastObjIndex: 0, - snippet: '', - snippetCreators: [], - archived: false, - creator: TEST_IDS.USER_1, - created: new Date(), - lastActive: new Date(), - }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User', - shortName: 'TU', - bot: false, - removed: false, - timezone: 'UTC', - userType: 'USER' as const, - version: 1, - }) + } as never) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue(mockCreator as never) - const result = await getWorkspaces.execute({}, mockTwistApi) + const result = await getWorkspaces.execute({}, mockCommsApi) const textContent = extractTextContent(result) expect(textContent).toContain( - `**Default Conversation:** [Conversation with users: ${TEST_IDS.USER_1}, ${TEST_IDS.USER_2}](https://twist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_1}/) (${TEST_IDS.CONVERSATION_1})`, + `**Default Conversation:** [Conversation with users: ${TEST_IDS.USER_1}, ${TEST_IDS.USER_2}](https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_1}/) (${TEST_IDS.CONVERSATION_1})`, ) }) it('should propagate API errors', async () => { const apiError = new Error(TEST_ERRORS.API_UNAUTHORIZED) - mockTwistApi.workspaces.getWorkspaces.mockRejectedValue(apiError) + mockCommsApi.workspaces.getWorkspaces.mockRejectedValue(apiError) - await expect(getWorkspaces.execute({}, mockTwistApi)).rejects.toThrow( + await expect(getWorkspaces.execute({}, mockCommsApi)).rejects.toThrow( TEST_ERRORS.API_UNAUTHORIZED, ) }) diff --git a/src/tools/__tests__/list-channels.test.ts b/src/tools/__tests__/list-channels.test.ts index dcdd291..f7f9dac 100644 --- a/src/tools/__tests__/list-channels.test.ts +++ b/src/tools/__tests__/list-channels.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { extractStructuredContent, @@ -9,19 +9,18 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { listChannels } from '../list-channels.js' -const mockTwistApi = { +const mockCommsApi = { channels: { getChannels: jest.fn(), }, workspaceUsers: { getUserById: jest.fn(), }, - batch: jest.fn(), -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { LIST_CHANNELS } = ToolNames -const createMockChannel = (overrides = {}) => ({ +const createMockChannel = (overrides: Partial> = {}) => ({ id: TEST_IDS.CHANNEL_1, name: 'General', creator: TEST_IDS.USER_1, @@ -36,36 +35,29 @@ const createMockChannel = (overrides = {}) => ({ describe(`${LIST_CHANNELS} tool`, () => { beforeEach(() => { jest.clearAllMocks() - mockTwistApi.batch.mockImplementation(async (...args: readonly unknown[]) => { - const results = [] - for (const arg of args) { - const result = await arg - results.push({ data: result }) - } - return results as never - }) }) describe('listing channels', () => { it('should list all channels in a workspace', async () => { + const otherChannelId = 'channel-id-other' const mockChannels = [ createMockChannel(), createMockChannel({ - id: 67891, + id: otherChannelId, name: 'Engineering', public: false, creator: TEST_IDS.USER_2, }), ] - mockTwistApi.channels.getChannels.mockResolvedValue(mockChannels) - mockTwistApi.workspaceUsers.getUserById.mockImplementation( + mockCommsApi.channels.getChannels.mockResolvedValue(mockChannels) + mockCommsApi.workspaceUsers.getUserById.mockImplementation( async (args: { workspaceId: number; userId: number }) => { if (args.userId === TEST_IDS.USER_1) { - return { name: 'Alice Johnson' } + return { fullName: 'Alice Johnson' } as never } if (args.userId === TEST_IDS.USER_2) { - return { name: 'Bob Smith' } + return { fullName: 'Bob Smith' } as never } throw new Error('User not found') }, @@ -73,10 +65,10 @@ describe(`${LIST_CHANNELS} tool`, () => { const result = await listChannels.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.channels.getChannels).toHaveBeenCalledWith({ + expect(mockCommsApi.channels.getChannels).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, }) @@ -104,7 +96,7 @@ describe(`${LIST_CHANNELS} tool`, () => { creatorName: 'Alice Johnson', }), expect.objectContaining({ - id: 67891, + id: otherChannelId, name: 'Engineering', public: false, creatorId: TEST_IDS.USER_2, @@ -115,11 +107,11 @@ describe(`${LIST_CHANNELS} tool`, () => { }) it('should handle empty channel list', async () => { - mockTwistApi.channels.getChannels.mockResolvedValue([]) + mockCommsApi.channels.getChannels.mockResolvedValue([]) const result = await listChannels.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -135,12 +127,14 @@ describe(`${LIST_CHANNELS} tool`, () => { }) it('should handle single channel', async () => { - mockTwistApi.channels.getChannels.mockResolvedValue([createMockChannel()]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ name: 'Alice Johnson' }) + mockCommsApi.channels.getChannels.mockResolvedValue([createMockChannel()]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice Johnson', + } as never) const result = await listChannels.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -153,14 +147,16 @@ describe(`${LIST_CHANNELS} tool`, () => { describe('channel details', () => { it('should include description when present', async () => { - mockTwistApi.channels.getChannels.mockResolvedValue([ + mockCommsApi.channels.getChannels.mockResolvedValue([ createMockChannel({ description: 'Main discussion channel' }), ]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ name: 'Alice' }) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice', + } as never) const result = await listChannels.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -174,12 +170,14 @@ describe(`${LIST_CHANNELS} tool`, () => { }) it('should omit description when not present', async () => { - mockTwistApi.channels.getChannels.mockResolvedValue([createMockChannel()]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ name: 'Alice' }) + mockCommsApi.channels.getChannels.mockResolvedValue([createMockChannel()]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice', + } as never) const result = await listChannels.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -190,14 +188,16 @@ describe(`${LIST_CHANNELS} tool`, () => { }) it('should show archived status', async () => { - mockTwistApi.channels.getChannels.mockResolvedValue([ + mockCommsApi.channels.getChannels.mockResolvedValue([ createMockChannel({ archived: true }), ]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ name: 'Alice' }) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice', + } as never) const result = await listChannels.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -208,12 +208,14 @@ describe(`${LIST_CHANNELS} tool`, () => { }) it('should include color when present', async () => { - mockTwistApi.channels.getChannels.mockResolvedValue([createMockChannel({ color: 5 })]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ name: 'Alice' }) + mockCommsApi.channels.getChannels.mockResolvedValue([createMockChannel({ color: 5 })]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice', + } as never) const result = await listChannels.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const structuredContent = extractStructuredContent(result) @@ -221,12 +223,14 @@ describe(`${LIST_CHANNELS} tool`, () => { }) it('should omit color when not present', async () => { - mockTwistApi.channels.getChannels.mockResolvedValue([createMockChannel()]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ name: 'Alice' }) + mockCommsApi.channels.getChannels.mockResolvedValue([createMockChannel()]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice', + } as never) const result = await listChannels.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) const structuredContent = extractStructuredContent(result) @@ -235,87 +239,109 @@ describe(`${LIST_CHANNELS} tool`, () => { }) describe('creator resolution', () => { - it('should batch-fetch creator names', async () => { + it('should deduplicate creator lookups', async () => { const mockChannels = [ createMockChannel({ creator: TEST_IDS.USER_1 }), - createMockChannel({ id: 67891, name: 'Other', creator: TEST_IDS.USER_1 }), - createMockChannel({ id: 67892, name: 'Third', creator: TEST_IDS.USER_2 }), + createMockChannel({ id: 'channel-id-2', name: 'Other', creator: TEST_IDS.USER_1 }), + createMockChannel({ id: 'channel-id-3', name: 'Third', creator: TEST_IDS.USER_2 }), ] - mockTwistApi.channels.getChannels.mockResolvedValue(mockChannels) - mockTwistApi.workspaceUsers.getUserById.mockImplementation( + mockCommsApi.channels.getChannels.mockResolvedValue(mockChannels) + mockCommsApi.workspaceUsers.getUserById.mockImplementation( async (args: { workspaceId: number; userId: number }) => { - if (args.userId === TEST_IDS.USER_1) return { name: 'Alice' } - if (args.userId === TEST_IDS.USER_2) return { name: 'Bob' } + if (args.userId === TEST_IDS.USER_1) return { fullName: 'Alice' } as never + if (args.userId === TEST_IDS.USER_2) return { fullName: 'Bob' } as never throw new Error('User not found') }, ) - await listChannels.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockTwistApi) + await listChannels.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockCommsApi) - // Should only batch 2 unique creators, not 3 - expect(mockTwistApi.batch).toHaveBeenCalledTimes(1) + // Should only fetch 2 unique creators, not 3 + expect(mockCommsApi.workspaceUsers.getUserById).toHaveBeenCalledTimes(2) }) - it('should handle missing creator gracefully', async () => { - mockTwistApi.channels.getChannels.mockResolvedValue([createMockChannel()]) - mockTwistApi.batch.mockResolvedValue([{ data: undefined }] as never) + it('falls back to the raw creator id when getUserById fails for that creator', async () => { + const mockChannels = [ + createMockChannel({ creator: TEST_IDS.USER_1 }), + createMockChannel({ id: 'channel-id-2', name: 'Other', creator: TEST_IDS.USER_2 }), + ] + + mockCommsApi.channels.getChannels.mockResolvedValue(mockChannels) + mockCommsApi.workspaceUsers.getUserById.mockImplementation( + async (args: { workspaceId: number; userId: number }) => { + if (args.userId === TEST_IDS.USER_1) return { fullName: 'Alice' } as never + throw new Error('User not found') + }, + ) const result = await listChannels.execute( { workspaceId: TEST_IDS.WORKSPACE_1 }, - mockTwistApi, + mockCommsApi, ) - const textContent = extractTextContent(result) - // Should fall back to showing creator ID - expect(textContent).toContain(`**Creator:** ${TEST_IDS.USER_1}`) + // Resolved creator gets a name; the failing one shows the raw ID + const text = extractTextContent(result) + expect(text).toContain(`Alice (${TEST_IDS.USER_1})`) + expect(text).toContain(`**Creator:** ${TEST_IDS.USER_2}`) - const structuredContent = extractStructuredContent(result) - expect(structuredContent.channels[0]).not.toHaveProperty('creatorName') + const structured = result.structuredContent as { + channels: Array<{ creatorId: number; creatorName?: string }> + } + const aliceChannel = structured.channels.find((c) => c.creatorId === TEST_IDS.USER_1) + const orphanChannel = structured.channels.find((c) => c.creatorId === TEST_IDS.USER_2) + expect(aliceChannel?.creatorName).toBe('Alice') + expect(orphanChannel?.creatorName).toBeUndefined() }) }) describe('includeArchived', () => { it('should only fetch active channels by default', async () => { - mockTwistApi.channels.getChannels.mockResolvedValue([createMockChannel()]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ name: 'Alice' }) + mockCommsApi.channels.getChannels.mockResolvedValue([createMockChannel()]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice', + } as never) - await listChannels.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockTwistApi) + await listChannels.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockCommsApi) - expect(mockTwistApi.channels.getChannels).toHaveBeenCalledTimes(1) - expect(mockTwistApi.channels.getChannels).toHaveBeenCalledWith({ + expect(mockCommsApi.channels.getChannels).toHaveBeenCalledTimes(1) + expect(mockCommsApi.channels.getChannels).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, }) }) - it('should batch-fetch active and archived channels when includeArchived is true', async () => { + it('should fetch active and archived channels in parallel when includeArchived is true', async () => { const activeChannel = createMockChannel({ name: 'Active' }) const archivedChannel = createMockChannel({ - id: 67891, + id: 'channel-id-2', name: 'Archived', archived: true, creator: TEST_IDS.USER_1, }) - mockTwistApi.channels.getChannels.mockImplementation(async (args) => { + mockCommsApi.channels.getChannels.mockImplementation(async (args) => { if ('archived' in args && args.archived === true) { return [archivedChannel] } return [activeChannel] }) - mockTwistApi.batch.mockResolvedValue([ - { data: [activeChannel] }, - { data: [archivedChannel] }, - ] as never) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ name: 'Alice' }) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice', + } as never) const result = await listChannels.execute( { workspaceId: TEST_IDS.WORKSPACE_1, includeArchived: true }, - mockTwistApi, + mockCommsApi, ) - // Should use batch for the two getChannels calls - expect(mockTwistApi.batch).toHaveBeenCalled() + expect(mockCommsApi.channels.getChannels).toHaveBeenCalledTimes(2) + expect(mockCommsApi.channels.getChannels).toHaveBeenCalledWith({ + workspaceId: TEST_IDS.WORKSPACE_1, + }) + expect(mockCommsApi.channels.getChannels).toHaveBeenCalledWith({ + workspaceId: TEST_IDS.WORKSPACE_1, + archived: true, + }) const structuredContent = extractStructuredContent(result) expect(structuredContent.totalChannels).toBe(2) @@ -331,10 +357,10 @@ describe(`${LIST_CHANNELS} tool`, () => { describe('error handling', () => { it('should propagate API errors', async () => { const apiError = new Error(TEST_ERRORS.API_UNAUTHORIZED) - mockTwistApi.channels.getChannels.mockRejectedValue(apiError) + mockCommsApi.channels.getChannels.mockRejectedValue(apiError) await expect( - listChannels.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockTwistApi), + listChannels.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockCommsApi), ).rejects.toThrow(TEST_ERRORS.API_UNAUTHORIZED) }) }) diff --git a/src/tools/__tests__/load-conversation.test.ts b/src/tools/__tests__/load-conversation.test.ts index d9977b2..4934b72 100644 --- a/src/tools/__tests__/load-conversation.test.ts +++ b/src/tools/__tests__/load-conversation.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { createMockConversation, @@ -9,9 +9,8 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { loadConversation } from '../load-conversation.js' -// Mock the Twist API -const mockTwistApi = { - batch: jest.fn(), +// Mock the Comms API +const mockCommsApi = { conversations: { getConversation: jest.fn(), }, @@ -21,22 +20,24 @@ const mockTwistApi = { workspaceUsers: { getUserById: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { LOAD_CONVERSATION } = ToolNames +const makeUser = (id: number, name: string) => ({ + id, + fullName: name, + shortName: name.split(' ')[0] ?? name, + email: `${name.toLowerCase().replace(/\s/g, '')}@test.com`, + userType: 'USER' as const, + removed: false, + timezone: 'UTC', + version: 1, +}) + describe(`${LOAD_CONVERSATION} tool`, () => { beforeEach(() => { jest.clearAllMocks() - // Mock batch to return responses with .data property - mockTwistApi.batch.mockImplementation(async (...args: readonly unknown[]) => { - const results = [] - for (const arg of args) { - const result = await arg - results.push({ data: result }) - } - return results as never - }) }) describe('loading conversations successfully', () => { @@ -49,69 +50,43 @@ describe(`${LOAD_CONVERSATION} tool`, () => { createMockConversationMessage({ id: TEST_IDS.MESSAGE_2 }), ] - mockTwistApi.conversations.getConversation.mockResolvedValue(mockConversation) - mockTwistApi.conversationMessages.getMessages.mockResolvedValue(mockMessages) - mockTwistApi.workspaceUsers.getUserById.mockImplementation((async (args: { + mockCommsApi.conversations.getConversation.mockResolvedValue(mockConversation) + mockCommsApi.conversationMessages.getMessages.mockResolvedValue(mockMessages) + mockCommsApi.workspaceUsers.getUserById.mockImplementation((async (args: { workspaceId: number userId: number }) => { if (args.userId === TEST_IDS.USER_1) { - return { - id: TEST_IDS.USER_1, - name: 'Test User 1', - shortName: 'TU1', - email: 'user1@test.com', - userType: 'USER' as const, - bot: false, - removed: false, - timezone: 'UTC', - version: 1, - } - } - return { - id: TEST_IDS.USER_2, - name: 'Test User 2', - shortName: 'TU2', - email: 'user2@test.com', - userType: 'USER' as const, - bot: false, - removed: false, - timezone: 'UTC', - version: 1, + return makeUser(TEST_IDS.USER_1, 'Test User 1') } + return makeUser(TEST_IDS.USER_2, 'Test User 2') }) as never) const result = await loadConversation.execute( { conversationId: TEST_IDS.CONVERSATION_1, limit: 50, includeParticipants: true }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.conversations.getConversation).toHaveBeenCalledWith( + expect(mockCommsApi.conversations.getConversation).toHaveBeenCalledWith( TEST_IDS.CONVERSATION_1, - { batch: true }, - ) - expect(mockTwistApi.conversationMessages.getMessages).toHaveBeenCalledWith( - { - conversationId: TEST_IDS.CONVERSATION_1, - newerThan: undefined, - olderThan: undefined, - limit: 50, - }, - { batch: true }, - ) - // Verify user info is fetched for each participant - expect(mockTwistApi.workspaceUsers.getUserById).toHaveBeenCalledWith( - { workspaceId: mockConversation.workspaceId, userId: TEST_IDS.USER_1 }, - { batch: true }, - ) - expect(mockTwistApi.workspaceUsers.getUserById).toHaveBeenCalledWith( - { workspaceId: mockConversation.workspaceId, userId: TEST_IDS.USER_2 }, - { batch: true }, ) + expect(mockCommsApi.conversationMessages.getMessages).toHaveBeenCalledWith({ + conversationId: TEST_IDS.CONVERSATION_1, + newerThan: undefined, + olderThan: undefined, + limit: 50, + }) + expect(mockCommsApi.workspaceUsers.getUserById).toHaveBeenCalledWith({ + workspaceId: mockConversation.workspaceId, + userId: TEST_IDS.USER_1, + }) + expect(mockCommsApi.workspaceUsers.getUserById).toHaveBeenCalledWith({ + workspaceId: mockConversation.workspaceId, + userId: TEST_IDS.USER_2, + }) expect(extractTextContent(result)).toMatchSnapshot() - // Verify structured content const { structuredContent } = result expect(structuredContent).toEqual( expect.objectContaining({ @@ -139,19 +114,11 @@ describe(`${LOAD_CONVERSATION} tool`, () => { const mockConversation = createMockConversation({ userIds: [TEST_IDS.USER_1, TEST_IDS.USER_2], }) - mockTwistApi.conversations.getConversation.mockResolvedValue(mockConversation) - mockTwistApi.conversationMessages.getMessages.mockResolvedValue([]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User 1', - shortName: 'TU1', - email: 'user1@test.com', - userType: 'USER' as const, - bot: false, - removed: false, - timezone: 'UTC', - version: 1, - }) + mockCommsApi.conversations.getConversation.mockResolvedValue(mockConversation) + mockCommsApi.conversationMessages.getMessages.mockResolvedValue([]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue( + makeUser(TEST_IDS.USER_1, 'Test User 1') as never, + ) const result = await loadConversation.execute( { @@ -159,7 +126,7 @@ describe(`${LOAD_CONVERSATION} tool`, () => { limit: 50, includeParticipants: false, }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -169,19 +136,11 @@ describe(`${LOAD_CONVERSATION} tool`, () => { it('should filter messages by date range', async () => { const mockConversation = createMockConversation() - mockTwistApi.conversations.getConversation.mockResolvedValue(mockConversation) - mockTwistApi.conversationMessages.getMessages.mockResolvedValue([]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User 1', - shortName: 'TU1', - email: 'user1@test.com', - userType: 'USER' as const, - bot: false, - removed: false, - timezone: 'UTC', - version: 1, - }) + mockCommsApi.conversations.getConversation.mockResolvedValue(mockConversation) + mockCommsApi.conversationMessages.getMessages.mockResolvedValue([]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue( + makeUser(TEST_IDS.USER_1, 'Test User 1') as never, + ) const result = await loadConversation.execute( { @@ -191,16 +150,14 @@ describe(`${LOAD_CONVERSATION} tool`, () => { limit: 50, includeParticipants: true, }, - mockTwistApi, + mockCommsApi, ) - // Verify dates were converted to Date objects - expect(mockTwistApi.conversationMessages.getMessages).toHaveBeenCalledWith( + expect(mockCommsApi.conversationMessages.getMessages).toHaveBeenCalledWith( expect.objectContaining({ newerThan: expect.any(Date), olderThan: expect.any(Date), }), - { batch: true }, ) expect(extractTextContent(result)).toMatchSnapshot() @@ -208,23 +165,15 @@ describe(`${LOAD_CONVERSATION} tool`, () => { it('should handle conversation with no messages', async () => { const mockConversation = createMockConversation() - mockTwistApi.conversations.getConversation.mockResolvedValue(mockConversation) - mockTwistApi.conversationMessages.getMessages.mockResolvedValue([]) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User 1', - shortName: 'TU1', - email: 'user1@test.com', - userType: 'USER' as const, - bot: false, - removed: false, - timezone: 'UTC', - version: 1, - }) + mockCommsApi.conversations.getConversation.mockResolvedValue(mockConversation) + mockCommsApi.conversationMessages.getMessages.mockResolvedValue([]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue( + makeUser(TEST_IDS.USER_1, 'Test User 1') as never, + ) const result = await loadConversation.execute( { conversationId: TEST_IDS.CONVERSATION_1, limit: 50, includeParticipants: true }, - mockTwistApi, + mockCommsApi, ) expect(extractTextContent(result)).toMatchSnapshot() @@ -234,7 +183,7 @@ describe(`${LOAD_CONVERSATION} tool`, () => { describe('error handling', () => { it('should propagate conversation not found error', async () => { const apiError = new Error('Conversation not found') - mockTwistApi.conversations.getConversation.mockRejectedValue(apiError) + mockCommsApi.conversations.getConversation.mockRejectedValue(apiError) await expect( loadConversation.execute( @@ -243,7 +192,7 @@ describe(`${LOAD_CONVERSATION} tool`, () => { limit: 50, includeParticipants: true, }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Conversation not found') }) diff --git a/src/tools/__tests__/load-thread.test.ts b/src/tools/__tests__/load-thread.test.ts index 8ade4a5..6ea1d22 100644 --- a/src/tools/__tests__/load-thread.test.ts +++ b/src/tools/__tests__/load-thread.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { createMockComment, @@ -9,9 +9,8 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { loadThread } from '../load-thread.js' -// Mock the Twist API -const mockTwistApi = { - batch: jest.fn(), +// Mock the Comms API +const mockCommsApi = { threads: { getThread: jest.fn(), }, @@ -24,22 +23,36 @@ const mockTwistApi = { workspaceUsers: { getUserById: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { LOAD_THREAD } = ToolNames +const makeChannel = () => ({ + id: TEST_IDS.CHANNEL_1, + name: 'Test Channel', + workspaceId: TEST_IDS.WORKSPACE_1, + created: new Date(), + archived: false, + public: true, + color: 0, + creator: TEST_IDS.USER_1, + version: 1, +}) + +const makeUser = (id: number, name: string) => ({ + id, + fullName: name, + shortName: name.split(' ')[0] ?? name, + email: `${name.toLowerCase().replace(/\s/g, '')}@test.com`, + userType: 'USER' as const, + removed: false, + timezone: 'UTC', + version: 1, +}) + describe(`${LOAD_THREAD} tool`, () => { beforeEach(() => { jest.clearAllMocks() - // Mock batch to return responses with .data property - mockTwistApi.batch.mockImplementation(async (...args: readonly unknown[]) => { - const results = [] - for (const arg of args) { - const result = await arg - results.push({ data: result }) - } - return results as never - }) }) describe('loading threads successfully', () => { @@ -52,69 +65,34 @@ describe(`${LOAD_THREAD} tool`, () => { createMockComment({ id: TEST_IDS.COMMENT_2, creator: TEST_IDS.USER_2 }), ] - mockTwistApi.threads.getThread.mockResolvedValue(mockThread) - mockTwistApi.comments.getComments.mockResolvedValue(mockComments) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: mockThread.channelId, - name: 'Test Channel', - workspaceId: TEST_IDS.WORKSPACE_1, - created: new Date(), - archived: false, - public: true, - color: 0, - creator: TEST_IDS.USER_1, - version: 1, - }) - mockTwistApi.workspaceUsers.getUserById.mockImplementation((async (args: { + mockCommsApi.threads.getThread.mockResolvedValue(mockThread) + mockCommsApi.comments.getComments.mockResolvedValue(mockComments) + mockCommsApi.channels.getChannel.mockResolvedValue(makeChannel()) + mockCommsApi.workspaceUsers.getUserById.mockImplementation((async (args: { workspaceId: number userId: number }) => { if (args.userId === TEST_IDS.USER_1) { - return { - id: TEST_IDS.USER_1, - name: 'Test User 1', - shortName: 'TU1', - email: 'user1@test.com', - userType: 'USER' as const, - bot: false, - removed: false, - timezone: 'UTC', - version: 1, - } - } - return { - id: TEST_IDS.USER_2, - name: 'Test User 2', - shortName: 'TU2', - email: 'user2@test.com', - userType: 'USER' as const, - bot: false, - removed: false, - timezone: 'UTC', - version: 1, + return makeUser(TEST_IDS.USER_1, 'Test User 1') } + return makeUser(TEST_IDS.USER_2, 'Test User 2') }) as never) const result = await loadThread.execute( { threadId: TEST_IDS.THREAD_1, limit: 50, includeParticipants: true }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.getThread).toHaveBeenCalledWith(TEST_IDS.THREAD_1, { - batch: true, + expect(mockCommsApi.threads.getThread).toHaveBeenCalledWith(TEST_IDS.THREAD_1) + expect(mockCommsApi.comments.getComments).toHaveBeenCalledWith({ + threadId: TEST_IDS.THREAD_1, + newerThan: undefined, + olderThan: undefined, + limit: 50, }) - expect(mockTwistApi.comments.getComments).toHaveBeenCalledWith( - { - threadId: TEST_IDS.THREAD_1, - from: undefined, - limit: 50, - }, - { batch: true }, - ) expect(extractTextContent(result)).toMatchSnapshot() - // Verify structured content const { structuredContent } = result expect(structuredContent).toEqual( expect.objectContaining({ @@ -150,30 +128,12 @@ describe(`${LOAD_THREAD} tool`, () => { const mockThread = createMockThread({ participants: [TEST_IDS.USER_1, TEST_IDS.USER_2], }) - mockTwistApi.threads.getThread.mockResolvedValue(mockThread) - mockTwistApi.comments.getComments.mockResolvedValue([]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: mockThread.channelId, - name: 'Test Channel', - workspaceId: TEST_IDS.WORKSPACE_1, - created: new Date(), - archived: false, - public: true, - color: 0, - creator: TEST_IDS.USER_1, - version: 1, - }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User 1', - shortName: 'TU1', - email: 'user1@test.com', - userType: 'USER' as const, - bot: false, - removed: false, - timezone: 'UTC', - version: 1, - }) + mockCommsApi.threads.getThread.mockResolvedValue(mockThread) + mockCommsApi.comments.getComments.mockResolvedValue([]) + mockCommsApi.channels.getChannel.mockResolvedValue(makeChannel()) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue( + makeUser(TEST_IDS.USER_1, 'Test User 1') as never, + ) const result = await loadThread.execute( { @@ -181,7 +141,7 @@ describe(`${LOAD_THREAD} tool`, () => { limit: 50, includeParticipants: false, }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -191,30 +151,12 @@ describe(`${LOAD_THREAD} tool`, () => { it('should filter comments by date', async () => { const mockThread = createMockThread() - mockTwistApi.threads.getThread.mockResolvedValue(mockThread) - mockTwistApi.comments.getComments.mockResolvedValue([]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: mockThread.channelId, - name: 'Test Channel', - workspaceId: TEST_IDS.WORKSPACE_1, - created: new Date(), - archived: false, - public: true, - color: 0, - creator: TEST_IDS.USER_1, - version: 1, - }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User 1', - shortName: 'TU1', - email: 'user1@test.com', - userType: 'USER' as const, - bot: false, - removed: false, - timezone: 'UTC', - version: 1, - }) + mockCommsApi.threads.getThread.mockResolvedValue(mockThread) + mockCommsApi.comments.getComments.mockResolvedValue([]) + mockCommsApi.channels.getChannel.mockResolvedValue(makeChannel()) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue( + makeUser(TEST_IDS.USER_1, 'Test User 1') as never, + ) const result = await loadThread.execute( { @@ -223,15 +165,13 @@ describe(`${LOAD_THREAD} tool`, () => { limit: 50, includeParticipants: true, }, - mockTwistApi, + mockCommsApi, ) - // Verify date was converted to Date object - expect(mockTwistApi.comments.getComments).toHaveBeenCalledWith( + expect(mockCommsApi.comments.getComments).toHaveBeenCalledWith( expect.objectContaining({ - from: expect.any(Date), + newerThan: expect.any(Date), }), - { batch: true }, ) expect(extractTextContent(result)).toMatchSnapshot() @@ -239,34 +179,16 @@ describe(`${LOAD_THREAD} tool`, () => { it('should handle thread with no comments', async () => { const mockThread = createMockThread() - mockTwistApi.threads.getThread.mockResolvedValue(mockThread) - mockTwistApi.comments.getComments.mockResolvedValue([]) - mockTwistApi.channels.getChannel.mockResolvedValue({ - id: mockThread.channelId, - name: 'Test Channel', - workspaceId: TEST_IDS.WORKSPACE_1, - created: new Date(), - archived: false, - public: true, - color: 0, - creator: TEST_IDS.USER_1, - version: 1, - }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ - id: TEST_IDS.USER_1, - name: 'Test User 1', - shortName: 'TU1', - email: 'user1@test.com', - userType: 'USER' as const, - bot: false, - removed: false, - timezone: 'UTC', - version: 1, - }) + mockCommsApi.threads.getThread.mockResolvedValue(mockThread) + mockCommsApi.comments.getComments.mockResolvedValue([]) + mockCommsApi.channels.getChannel.mockResolvedValue(makeChannel()) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue( + makeUser(TEST_IDS.USER_1, 'Test User 1') as never, + ) const result = await loadThread.execute( { threadId: TEST_IDS.THREAD_1, limit: 50, includeParticipants: true }, - mockTwistApi, + mockCommsApi, ) expect(extractTextContent(result)).toMatchSnapshot() @@ -276,12 +198,12 @@ describe(`${LOAD_THREAD} tool`, () => { describe('error handling', () => { it('should propagate thread not found error', async () => { const apiError = new Error('Thread not found') - mockTwistApi.threads.getThread.mockRejectedValue(apiError) + mockCommsApi.threads.getThread.mockRejectedValue(apiError) await expect( loadThread.execute( { threadId: TEST_IDS.THREAD_1, limit: 50, includeParticipants: true }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Thread not found') }) diff --git a/src/tools/__tests__/mark-done.test.ts b/src/tools/__tests__/mark-done.test.ts index 823b85d..4c20bd3 100644 --- a/src/tools/__tests__/mark-done.test.ts +++ b/src/tools/__tests__/mark-done.test.ts @@ -1,12 +1,11 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { extractTextContent, TEST_IDS } from '../../utils/test-helpers.js' import { ToolNames } from '../../utils/tool-names.js' import { markDone } from '../mark-done.js' -// Mock the Twist API -const mockTwistApi = { - batch: jest.fn(), +// Mock the Comms API +const mockCommsApi = { threads: { markRead: jest.fn(), markAllRead: jest.fn(), @@ -20,76 +19,29 @@ const mockTwistApi = { archiveThread: jest.fn(), archiveAll: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { MARK_DONE } = ToolNames describe(`${MARK_DONE} tool`, () => { - // Store original console.error const originalConsoleError = console.error beforeEach(() => { jest.clearAllMocks() - // By default, batch succeeds - mockTwistApi.batch.mockResolvedValue([] as never) - // Suppress console.error for tests that expect errors console.error = jest.fn() - // Setup mocks to return batch descriptors when called with {batch: true} - mockTwistApi.threads.markRead.mockImplementation( - (args: { id: number; objIndex: number }, options?: { batch?: boolean }) => { - if (options?.batch) { - return { - method: 'POST', - url: '/threads/mark_read', - params: { id: args.id, obj_index: args.objIndex }, - } as never - } - return Promise.resolve(undefined) as never - }, - ) - mockTwistApi.inbox.archiveThread.mockImplementation( - (id: number, options?: { batch?: boolean }) => { - if (options?.batch) { - return { method: 'POST', url: '/inbox/archive', params: { id } } as never - } - return Promise.resolve(undefined) as never - }, - ) - mockTwistApi.conversations.markRead.mockImplementation( - (args: { id: number }, options?: { batch?: boolean }) => { - if (options?.batch) { - return { - method: 'POST', - url: '/conversations/mark_read', - params: args, - } as never - } - return Promise.resolve(undefined) as never - }, - ) - mockTwistApi.conversations.archiveConversation.mockImplementation( - (id: number, options?: { batch?: boolean }) => { - if (options?.batch) { - return { - method: 'POST', - url: '/conversations/archive', - params: { id }, - } as never - } - return Promise.resolve(undefined) as never - }, - ) + mockCommsApi.threads.markRead.mockResolvedValue(undefined as never) + mockCommsApi.inbox.archiveThread.mockResolvedValue(undefined as never) + mockCommsApi.conversations.markRead.mockResolvedValue(undefined as never) + mockCommsApi.conversations.archiveConversation.mockResolvedValue(undefined as never) }) afterEach(() => { - // Restore console.error console.error = originalConsoleError }) describe('marking threads as done', () => { it('should mark all threads as done successfully', async () => { - // Don't override mock implementations - they're set up in beforeEach to handle batch mode const result = await markDone.execute( { type: 'thread', @@ -97,24 +49,15 @@ describe(`${MARK_DONE} tool`, () => { markRead: true, archive: true, }, - mockTwistApi, + mockCommsApi, ) - // Verify batch was called (operations are batched) - expect(mockTwistApi.batch).toHaveBeenCalledTimes(1) - expect(mockTwistApi.batch).toHaveBeenCalledWith( - expect.objectContaining({ method: 'POST', url: '/threads/mark_read' }), - expect.objectContaining({ method: 'POST', url: '/inbox/archive' }), - expect.objectContaining({ method: 'POST', url: '/threads/mark_read' }), - expect.objectContaining({ method: 'POST', url: '/inbox/archive' }), - expect.objectContaining({ method: 'POST', url: '/threads/mark_read' }), - expect.objectContaining({ method: 'POST', url: '/inbox/archive' }), - ) + // markRead + archiveThread for each thread = 3 of each + expect(mockCommsApi.threads.markRead).toHaveBeenCalledTimes(3) + expect(mockCommsApi.inbox.archiveThread).toHaveBeenCalledTimes(3) - // Verify result is a concise summary expect(extractTextContent(result)).toMatchSnapshot() - // Verify structured content const { structuredContent } = result expect(structuredContent).toEqual( expect.objectContaining({ @@ -136,8 +79,6 @@ describe(`${MARK_DONE} tool`, () => { }) it('should mark thread as read only', async () => { - mockTwistApi.threads.markRead.mockResolvedValue(undefined) - const result = await markDone.execute( { type: 'thread', @@ -145,18 +86,16 @@ describe(`${MARK_DONE} tool`, () => { markRead: true, archive: false, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.markRead).toHaveBeenCalledTimes(1) - expect(mockTwistApi.inbox.archiveThread).not.toHaveBeenCalled() + expect(mockCommsApi.threads.markRead).toHaveBeenCalledTimes(1) + expect(mockCommsApi.inbox.archiveThread).not.toHaveBeenCalled() expect(extractTextContent(result)).toMatchSnapshot() }) it('should archive thread only', async () => { - mockTwistApi.inbox.archiveThread.mockResolvedValue(undefined) - const result = await markDone.execute( { type: 'thread', @@ -164,51 +103,25 @@ describe(`${MARK_DONE} tool`, () => { markRead: false, archive: true, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.markRead).not.toHaveBeenCalled() - expect(mockTwistApi.inbox.archiveThread).toHaveBeenCalledTimes(1) + expect(mockCommsApi.threads.markRead).not.toHaveBeenCalled() + expect(mockCommsApi.inbox.archiveThread).toHaveBeenCalledTimes(1) expect(extractTextContent(result)).toMatchSnapshot() }) it('should handle partial failures gracefully', async () => { - // Mock batch to fail, triggering fallback to individual operations - mockTwistApi.batch.mockRejectedValueOnce(new Error('Batch failed')) - - // Don't reset mocks - the batch descriptor implementations need to stay in place - // But we need to set up the fallback behavior for when batch fails - // The mock implementation in beforeEach handles {batch: true}, but when called without batch option, - // it returns Promise.resolve(undefined). We need to override this for the fallback calls. - - // When batch fails, the code will call these functions again WITHOUT {batch: true} - // We need to set up separate behavior for those non-batch calls - // First 3 calls are for building batch descriptors (with {batch: true}) - // Next 3 calls are the fallback (without {batch: true}) - let markReadCallCount = 0 - mockTwistApi.threads.markRead.mockImplementation( - (args: { id: number; objIndex: number }, options?: { batch?: boolean }) => { - markReadCallCount++ - // First 3 calls: return batch descriptors - if (markReadCallCount <= 3 && options?.batch) { - return { - method: 'POST', - url: '/threads/mark_read', - params: { id: args.id, obj_index: args.objIndex }, - } as never + mockCommsApi.threads.markRead.mockImplementation( + async (args: { id: string; objIndex: number }) => { + if (args.id === TEST_IDS.THREAD_2) { + throw new Error('Thread not found') } - // Next 3 calls: fallback behavior - if (markReadCallCount === 4) return Promise.resolve(undefined) as never // thread-1 succeeds - if (markReadCallCount === 5) - return Promise.reject(new Error('Thread not found')) as never // thread-2 fails - if (markReadCallCount === 6) return Promise.resolve(undefined) as never // thread-3 succeeds - return Promise.resolve(undefined) as never + return undefined }, ) - mockTwistApi.inbox.archiveThread.mockResolvedValue(undefined) - const result = await markDone.execute( { type: 'thread', @@ -216,13 +129,11 @@ describe(`${MARK_DONE} tool`, () => { markRead: true, archive: true, }, - mockTwistApi, + mockCommsApi, ) - // Verify only successful completions are reported expect(extractTextContent(result)).toMatchSnapshot() - // Verify structured content with partial failures const { structuredContent } = result expect(structuredContent).toEqual( expect.objectContaining({ @@ -241,11 +152,7 @@ describe(`${MARK_DONE} tool`, () => { }) it('should handle all threads failing', async () => { - // Mock batch to fail, triggering fallback to individual operations - mockTwistApi.batch.mockRejectedValueOnce(new Error('Batch failed')) - - const apiError = new Error('API Error: Network timeout') - mockTwistApi.threads.markRead.mockRejectedValue(apiError) + mockCommsApi.threads.markRead.mockRejectedValue(new Error('API Error: Network timeout')) const result = await markDone.execute( { @@ -254,17 +161,15 @@ describe(`${MARK_DONE} tool`, () => { markRead: true, archive: true, }, - mockTwistApi, + mockCommsApi, ) - // Verify no threads were completed expect(extractTextContent(result)).toMatchSnapshot() }) }) describe('marking conversations as done', () => { it('should mark all conversations as done successfully', async () => { - // Don't override mock implementations - they're set up in beforeEach to handle batch mode const result = await markDone.execute( { type: 'conversation', @@ -272,21 +177,14 @@ describe(`${MARK_DONE} tool`, () => { markRead: true, archive: true, }, - mockTwistApi, + mockCommsApi, ) - // Verify batch was called (operations are batched) - expect(mockTwistApi.batch).toHaveBeenCalledTimes(1) - expect(mockTwistApi.batch).toHaveBeenCalledWith( - expect.objectContaining({ method: 'POST', url: '/conversations/mark_read' }), - expect.objectContaining({ method: 'POST', url: '/conversations/archive' }), - expect.objectContaining({ method: 'POST', url: '/conversations/mark_read' }), - expect.objectContaining({ method: 'POST', url: '/conversations/archive' }), - ) + expect(mockCommsApi.conversations.markRead).toHaveBeenCalledTimes(2) + expect(mockCommsApi.conversations.archiveConversation).toHaveBeenCalledTimes(2) expect(extractTextContent(result)).toMatchSnapshot() - // Verify structured content const { structuredContent } = result expect(structuredContent).toEqual( expect.objectContaining({ @@ -301,7 +199,7 @@ describe(`${MARK_DONE} tool`, () => { }) it('should handle conversation not found error', async () => { - mockTwistApi.conversations.markRead.mockRejectedValue( + mockCommsApi.conversations.markRead.mockRejectedValue( new Error('Conversation not found'), ) @@ -312,7 +210,7 @@ describe(`${MARK_DONE} tool`, () => { markRead: true, archive: false, }, - mockTwistApi, + mockCommsApi, ) expect(extractTextContent(result)).toMatchSnapshot() @@ -321,21 +219,21 @@ describe(`${MARK_DONE} tool`, () => { describe('bulk thread operations', () => { it('should mark all threads as read and archive in a workspace', async () => { - mockTwistApi.threads.markAllRead.mockResolvedValue(undefined) - mockTwistApi.inbox.archiveAll.mockResolvedValue(undefined) + mockCommsApi.threads.markAllRead.mockResolvedValue(undefined as never) + mockCommsApi.inbox.archiveAll.mockResolvedValue(undefined as never) const result = await markDone.execute( { type: 'thread', workspaceId: TEST_IDS.WORKSPACE_1, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.markAllRead).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.markAllRead).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, }) - expect(mockTwistApi.inbox.archiveAll).toHaveBeenCalledWith({ + expect(mockCommsApi.inbox.archiveAll).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, }) @@ -360,31 +258,91 @@ describe(`${MARK_DONE} tool`, () => { ) }) - it('should mark all threads as read and archive in a channel', async () => { - mockTwistApi.threads.markAllRead.mockResolvedValue(undefined) - mockTwistApi.inbox.archiveAll.mockResolvedValue(undefined) + it('rejects channel-only bulk archive (would silently drop the archive step)', async () => { + mockCommsApi.threads.markAllRead.mockResolvedValue(undefined as never) + mockCommsApi.inbox.archiveAll.mockResolvedValue(undefined as never) + + // `archive: true` set explicitly so the test pins the rejection to + // the archive-without-workspaceId rule rather than the tool's + // default. If the default ever changes, this case still tests what + // it claims to. + await expect( + markDone.execute( + { + type: 'thread', + channelId: TEST_IDS.CHANNEL_1, + archive: true, + }, + mockCommsApi, + ), + ).rejects.toThrow('Archiving by channelId requires workspaceId') + + expect(mockCommsApi.threads.markAllRead).not.toHaveBeenCalled() + expect(mockCommsApi.inbox.archiveAll).not.toHaveBeenCalled() + }) + + it('marks all read in a channel without archiving when archive=false', async () => { + mockCommsApi.threads.markAllRead.mockResolvedValue(undefined as never) const result = await markDone.execute( { type: 'thread', channelId: TEST_IDS.CHANNEL_1, + archive: false, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.markAllRead).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.markAllRead).toHaveBeenCalledWith({ channelId: TEST_IDS.CHANNEL_1, }) - expect(mockTwistApi.inbox.archiveAll).toHaveBeenCalledWith({ - workspaceId: 0, + expect(mockCommsApi.inbox.archiveAll).not.toHaveBeenCalled() + + expect(extractTextContent(result)).toMatchSnapshot() + }) + + it('scopes archive to a channel when workspaceId + channelId are both provided', async () => { + mockCommsApi.threads.markAllRead.mockResolvedValue(undefined as never) + mockCommsApi.inbox.archiveAll.mockResolvedValue(undefined as never) + + const result = await markDone.execute( + { + type: 'thread', + workspaceId: TEST_IDS.WORKSPACE_1, + channelId: TEST_IDS.CHANNEL_1, + archive: true, + }, + mockCommsApi, + ) + + expect(mockCommsApi.threads.markAllRead).toHaveBeenCalledWith({ + workspaceId: TEST_IDS.WORKSPACE_1, + channelId: TEST_IDS.CHANNEL_1, + }) + expect(mockCommsApi.inbox.archiveAll).toHaveBeenCalledWith({ + workspaceId: TEST_IDS.WORKSPACE_1, channelIds: [TEST_IDS.CHANNEL_1], }) - expect(extractTextContent(result)).toMatchSnapshot() + // Also pin the user-visible reporting so a regression in the + // selectors payload can't slip through with the SDK calls green. + const structured = result.structuredContent as { + mode: string + selectors?: { workspaceId?: number; channelId?: string } + } + expect(structured.mode).toBe('bulk') + expect(structured.selectors).toEqual({ + workspaceId: TEST_IDS.WORKSPACE_1, + channelId: TEST_IDS.CHANNEL_1, + }) + + const text = extractTextContent(result) + expect(text).toContain(`**Workspace ID:** ${TEST_IDS.WORKSPACE_1}`) + expect(text).toContain(`**Channel ID:** ${TEST_IDS.CHANNEL_1}`) }) it('should clear all unread markers in a workspace', async () => { - mockTwistApi.threads.clearUnread.mockResolvedValue(undefined) + mockCommsApi.threads.clearUnread.mockResolvedValue(undefined as never) const result = await markDone.execute( { @@ -392,18 +350,18 @@ describe(`${MARK_DONE} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, clearUnread: true, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.clearUnread).toHaveBeenCalledWith(TEST_IDS.WORKSPACE_1) - expect(mockTwistApi.threads.markAllRead).not.toHaveBeenCalled() - expect(mockTwistApi.inbox.archiveAll).not.toHaveBeenCalled() + expect(mockCommsApi.threads.clearUnread).toHaveBeenCalledWith(TEST_IDS.WORKSPACE_1) + expect(mockCommsApi.threads.markAllRead).not.toHaveBeenCalled() + expect(mockCommsApi.inbox.archiveAll).not.toHaveBeenCalled() expect(extractTextContent(result)).toMatchSnapshot() }) it('should mark all as read without archiving in workspace', async () => { - mockTwistApi.threads.markAllRead.mockResolvedValue(undefined) + mockCommsApi.threads.markAllRead.mockResolvedValue(undefined as never) const result = await markDone.execute( { @@ -411,19 +369,19 @@ describe(`${MARK_DONE} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, archive: false, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.markAllRead).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.markAllRead).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, }) - expect(mockTwistApi.inbox.archiveAll).not.toHaveBeenCalled() + expect(mockCommsApi.inbox.archiveAll).not.toHaveBeenCalled() expect(extractTextContent(result)).toMatchSnapshot() }) it('should archive all without marking as read in workspace', async () => { - mockTwistApi.inbox.archiveAll.mockResolvedValue(undefined) + mockCommsApi.inbox.archiveAll.mockResolvedValue(undefined as never) const result = await markDone.execute( { @@ -431,11 +389,11 @@ describe(`${MARK_DONE} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, markRead: false, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.markAllRead).not.toHaveBeenCalled() - expect(mockTwistApi.inbox.archiveAll).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.markAllRead).not.toHaveBeenCalled() + expect(mockCommsApi.inbox.archiveAll).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, }) @@ -449,7 +407,7 @@ describe(`${MARK_DONE} tool`, () => { type: 'conversation', workspaceId: TEST_IDS.WORKSPACE_1, }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow( 'Bulk operations (workspaceId, channelId, clearUnread) are only supported for threads', @@ -464,7 +422,7 @@ describe(`${MARK_DONE} tool`, () => { ids: [TEST_IDS.CONVERSATION_1], clearUnread: true, }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow( 'Bulk operations (workspaceId, channelId, clearUnread) are only supported for threads', @@ -473,7 +431,7 @@ describe(`${MARK_DONE} tool`, () => { it('should propagate bulk operation errors', async () => { const apiError = new Error('Workspace not found') - mockTwistApi.threads.markAllRead.mockRejectedValue(apiError) + mockCommsApi.threads.markAllRead.mockRejectedValue(apiError) await expect( markDone.execute( @@ -481,7 +439,7 @@ describe(`${MARK_DONE} tool`, () => { type: 'thread', workspaceId: TEST_IDS.WORKSPACE_1, }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Bulk operation failed: Workspace not found') }) @@ -492,7 +450,7 @@ describe(`${MARK_DONE} tool`, () => { { type: 'thread', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Must provide either ids, workspaceId, or channelId') }) @@ -500,9 +458,6 @@ describe(`${MARK_DONE} tool`, () => { describe('next steps logic validation', () => { it('should suggest fetch-inbox when all threads complete successfully', async () => { - mockTwistApi.threads.markRead.mockResolvedValue(undefined) - mockTwistApi.inbox.archiveThread.mockResolvedValue(undefined) - const result = await markDone.execute( { type: 'thread', @@ -510,7 +465,7 @@ describe(`${MARK_DONE} tool`, () => { markRead: true, archive: true, }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -519,32 +474,15 @@ describe(`${MARK_DONE} tool`, () => { }) it('should suggest reviewing failures when mixed results', async () => { - // Mock batch to fail, triggering fallback to individual operations - mockTwistApi.batch.mockRejectedValueOnce(new Error('Batch failed')) - - // Setup mock implementation to handle batch calls first, then fallback calls - let markReadCallCount = 0 - mockTwistApi.threads.markRead.mockImplementation( - (args: { id: number; objIndex: number }, options?: { batch?: boolean }) => { - markReadCallCount++ - // First 2 calls: return batch descriptors (with {batch: true}) - if (markReadCallCount <= 2 && options?.batch) { - return { - method: 'POST', - url: '/threads/mark_read', - params: { id: args.id, obj_index: args.objIndex }, - } as never + mockCommsApi.threads.markRead.mockImplementation( + async (args: { id: string; objIndex: number }) => { + if (args.id === TEST_IDS.THREAD_2) { + throw new Error('Thread not found') } - // Next 2 calls: fallback behavior (without {batch: true}) - if (markReadCallCount === 3) return Promise.resolve(undefined) as never // thread-1 succeeds - if (markReadCallCount === 4) - return Promise.reject(new Error('Thread not found')) as never // thread-2 fails - return Promise.resolve(undefined) as never + return undefined }, ) - mockTwistApi.inbox.archiveThread.mockResolvedValue(undefined) - const result = await markDone.execute( { type: 'thread', @@ -552,7 +490,7 @@ describe(`${MARK_DONE} tool`, () => { markRead: true, archive: true, }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) diff --git a/src/tools/__tests__/react.test.ts b/src/tools/__tests__/react.test.ts index 15a194a..a09386b 100644 --- a/src/tools/__tests__/react.test.ts +++ b/src/tools/__tests__/react.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { createMockComment, @@ -10,8 +10,8 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { react } from '../react.js' -// Mock the Twist API -const mockTwistApi = { +// Mock the Comms API +const mockCommsApi = { threads: { getThread: jest.fn(), }, @@ -28,7 +28,7 @@ const mockTwistApi = { add: jest.fn(), remove: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { REACT } = ToolNames @@ -39,10 +39,10 @@ describe(`${REACT} tool`, () => { describe('adding reactions', () => { it('should add reaction to a thread', async () => { - mockTwistApi.threads.getThread.mockResolvedValue( + mockCommsApi.threads.getThread.mockResolvedValue( createMockThread({ id: TEST_IDS.THREAD_1 }), ) - mockTwistApi.reactions.add.mockResolvedValue(undefined) + mockCommsApi.reactions.add.mockResolvedValue(undefined) const result = await react.execute( { @@ -51,11 +51,11 @@ describe(`${REACT} tool`, () => { emoji: '👍', operation: 'add', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.getThread).toHaveBeenCalledWith(TEST_IDS.THREAD_1) - expect(mockTwistApi.reactions.add).toHaveBeenCalledWith( + expect(mockCommsApi.threads.getThread).toHaveBeenCalledWith(TEST_IDS.THREAD_1) + expect(mockCommsApi.reactions.add).toHaveBeenCalledWith( expect.objectContaining({ threadId: TEST_IDS.THREAD_1, reaction: '👍', @@ -73,15 +73,15 @@ describe(`${REACT} tool`, () => { targetType: 'thread', targetId: TEST_IDS.THREAD_1, emoji: '👍', - targetUrl: expect.stringContaining('twist.com'), + targetUrl: expect.stringContaining('comms.todoist.com'), }) }) it('should add reaction to a comment', async () => { - mockTwistApi.comments.getComment.mockResolvedValue( + mockCommsApi.comments.getComment.mockResolvedValue( createMockComment({ id: TEST_IDS.COMMENT_1, threadId: TEST_IDS.THREAD_1 }), ) - mockTwistApi.reactions.add.mockResolvedValue(undefined) + mockCommsApi.reactions.add.mockResolvedValue(undefined) const result = await react.execute( { @@ -90,11 +90,11 @@ describe(`${REACT} tool`, () => { emoji: '❤️', operation: 'add', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.getComment).toHaveBeenCalledWith(TEST_IDS.COMMENT_1) - expect(mockTwistApi.reactions.add).toHaveBeenCalledWith( + expect(mockCommsApi.comments.getComment).toHaveBeenCalledWith(TEST_IDS.COMMENT_1) + expect(mockCommsApi.reactions.add).toHaveBeenCalledWith( expect.objectContaining({ commentId: TEST_IDS.COMMENT_1, reaction: '❤️', @@ -105,13 +105,13 @@ describe(`${REACT} tool`, () => { }) it('should add reaction to a message', async () => { - mockTwistApi.conversationMessages.getMessage.mockResolvedValue( + mockCommsApi.conversationMessages.getMessage.mockResolvedValue( createMockConversationMessage({ id: TEST_IDS.MESSAGE_1, conversationId: TEST_IDS.CONVERSATION_1, }), ) - mockTwistApi.reactions.add.mockResolvedValue(undefined) + mockCommsApi.reactions.add.mockResolvedValue(undefined) const result = await react.execute( { @@ -120,13 +120,13 @@ describe(`${REACT} tool`, () => { emoji: '🎉', operation: 'add', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.conversationMessages.getMessage).toHaveBeenCalledWith( + expect(mockCommsApi.conversationMessages.getMessage).toHaveBeenCalledWith( TEST_IDS.MESSAGE_1, ) - expect(mockTwistApi.reactions.add).toHaveBeenCalledWith( + expect(mockCommsApi.reactions.add).toHaveBeenCalledWith( expect.objectContaining({ messageId: TEST_IDS.MESSAGE_1, reaction: '🎉', @@ -139,10 +139,10 @@ describe(`${REACT} tool`, () => { describe('removing reactions', () => { it('should remove reaction from a thread', async () => { - mockTwistApi.threads.getThread.mockResolvedValue( + mockCommsApi.threads.getThread.mockResolvedValue( createMockThread({ id: TEST_IDS.THREAD_1 }), ) - mockTwistApi.reactions.remove.mockResolvedValue(undefined) + mockCommsApi.reactions.remove.mockResolvedValue(undefined) const result = await react.execute( { @@ -151,11 +151,11 @@ describe(`${REACT} tool`, () => { emoji: '👍', operation: 'remove', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.getThread).toHaveBeenCalledWith(TEST_IDS.THREAD_1) - expect(mockTwistApi.reactions.remove).toHaveBeenCalledWith( + expect(mockCommsApi.threads.getThread).toHaveBeenCalledWith(TEST_IDS.THREAD_1) + expect(mockCommsApi.reactions.remove).toHaveBeenCalledWith( expect.objectContaining({ threadId: TEST_IDS.THREAD_1, reaction: '👍', @@ -166,10 +166,10 @@ describe(`${REACT} tool`, () => { }) it('should remove reaction from a comment', async () => { - mockTwistApi.comments.getComment.mockResolvedValue( + mockCommsApi.comments.getComment.mockResolvedValue( createMockComment({ id: TEST_IDS.COMMENT_1, threadId: TEST_IDS.THREAD_1 }), ) - mockTwistApi.reactions.remove.mockResolvedValue(undefined) + mockCommsApi.reactions.remove.mockResolvedValue(undefined) const result = await react.execute( { @@ -178,11 +178,11 @@ describe(`${REACT} tool`, () => { emoji: '❤️', operation: 'remove', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.getComment).toHaveBeenCalledWith(TEST_IDS.COMMENT_1) - expect(mockTwistApi.reactions.remove).toHaveBeenCalledWith( + expect(mockCommsApi.comments.getComment).toHaveBeenCalledWith(TEST_IDS.COMMENT_1) + expect(mockCommsApi.reactions.remove).toHaveBeenCalledWith( expect.objectContaining({ commentId: TEST_IDS.COMMENT_1, reaction: '❤️', @@ -193,13 +193,13 @@ describe(`${REACT} tool`, () => { }) it('should remove reaction from a message', async () => { - mockTwistApi.conversationMessages.getMessage.mockResolvedValue( + mockCommsApi.conversationMessages.getMessage.mockResolvedValue( createMockConversationMessage({ id: TEST_IDS.MESSAGE_1, conversationId: TEST_IDS.CONVERSATION_1, }), ) - mockTwistApi.reactions.remove.mockResolvedValue(undefined) + mockCommsApi.reactions.remove.mockResolvedValue(undefined) const result = await react.execute( { @@ -208,13 +208,13 @@ describe(`${REACT} tool`, () => { emoji: '🎉', operation: 'remove', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.conversationMessages.getMessage).toHaveBeenCalledWith( + expect(mockCommsApi.conversationMessages.getMessage).toHaveBeenCalledWith( TEST_IDS.MESSAGE_1, ) - expect(mockTwistApi.reactions.remove).toHaveBeenCalledWith( + expect(mockCommsApi.reactions.remove).toHaveBeenCalledWith( expect.objectContaining({ messageId: TEST_IDS.MESSAGE_1, reaction: '🎉', @@ -227,11 +227,11 @@ describe(`${REACT} tool`, () => { describe('error handling', () => { it('should propagate add reaction errors', async () => { - mockTwistApi.threads.getThread.mockResolvedValue( + mockCommsApi.threads.getThread.mockResolvedValue( createMockThread({ id: TEST_IDS.THREAD_1 }), ) const apiError = new Error('Thread not found') - mockTwistApi.reactions.add.mockRejectedValue(apiError) + mockCommsApi.reactions.add.mockRejectedValue(apiError) await expect( react.execute( @@ -241,17 +241,17 @@ describe(`${REACT} tool`, () => { emoji: '👍', operation: 'add', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Thread not found') }) it('should propagate remove reaction errors', async () => { - mockTwistApi.comments.getComment.mockResolvedValue( + mockCommsApi.comments.getComment.mockResolvedValue( createMockComment({ id: TEST_IDS.COMMENT_1, threadId: TEST_IDS.THREAD_1 }), ) const apiError = new Error('Reaction not found') - mockTwistApi.reactions.remove.mockRejectedValue(apiError) + mockCommsApi.reactions.remove.mockRejectedValue(apiError) await expect( react.execute( @@ -261,7 +261,7 @@ describe(`${REACT} tool`, () => { emoji: '❤️', operation: 'remove', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Reaction not found') }) diff --git a/src/tools/__tests__/reply.test.ts b/src/tools/__tests__/reply.test.ts index 6dd01e7..8f24cb1 100644 --- a/src/tools/__tests__/reply.test.ts +++ b/src/tools/__tests__/reply.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { createMockComment, @@ -10,15 +10,15 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { reply } from '../reply.js' -// Mock the Twist API -const mockTwistApi = { +// Mock the Comms API +const mockCommsApi = { comments: { createComment: jest.fn(), }, conversationMessages: { createMessage: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { REPLY } = ToolNames @@ -30,7 +30,7 @@ describe(`${REPLY} tool`, () => { describe('replying to threads', () => { it('should post a comment to a thread', async () => { const mockComment = createMockComment() - mockTwistApi.comments.createComment.mockResolvedValue(mockComment) + mockCommsApi.comments.createComment.mockResolvedValue(mockComment) const result = await reply.execute( { @@ -38,10 +38,10 @@ describe(`${REPLY} tool`, () => { targetId: TEST_IDS.THREAD_1, content: 'This is my reply', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.createComment).toHaveBeenCalledWith({ + expect(mockCommsApi.comments.createComment).toHaveBeenCalledWith({ threadId: TEST_IDS.THREAD_1, content: 'This is my reply', recipients: undefined, @@ -60,7 +60,7 @@ describe(`${REPLY} tool`, () => { targetType: 'thread', targetId: TEST_IDS.THREAD_1, content: 'This is my reply', - replyUrl: expect.stringContaining('twist.com'), + replyUrl: expect.stringContaining('comms.todoist.com'), }), ) expect(structuredContent?.replyId).toBe(mockComment.id) @@ -70,7 +70,7 @@ describe(`${REPLY} tool`, () => { it('should post a comment with recipients', async () => { const mockComment = createMockComment() - mockTwistApi.comments.createComment.mockResolvedValue(mockComment) + mockCommsApi.comments.createComment.mockResolvedValue(mockComment) const result = await reply.execute( { @@ -79,10 +79,10 @@ describe(`${REPLY} tool`, () => { content: 'Notifying users', recipients: [TEST_IDS.USER_1, TEST_IDS.USER_2], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.createComment).toHaveBeenCalledWith({ + expect(mockCommsApi.comments.createComment).toHaveBeenCalledWith({ threadId: TEST_IDS.THREAD_1, content: 'Notifying users', recipients: [TEST_IDS.USER_1, TEST_IDS.USER_2], @@ -100,7 +100,7 @@ describe(`${REPLY} tool`, () => { it('should post a comment with groups', async () => { const mockComment = createMockComment() - mockTwistApi.comments.createComment.mockResolvedValue(mockComment) + mockCommsApi.comments.createComment.mockResolvedValue(mockComment) const result = await reply.execute( { @@ -109,10 +109,10 @@ describe(`${REPLY} tool`, () => { content: 'Notifying groups', groups: [100, 200], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.createComment).toHaveBeenCalledWith({ + expect(mockCommsApi.comments.createComment).toHaveBeenCalledWith({ threadId: TEST_IDS.THREAD_1, content: 'Notifying groups', recipients: undefined, @@ -128,7 +128,7 @@ describe(`${REPLY} tool`, () => { it('should default to notifyAudience: thread when groups are empty', async () => { const mockComment = createMockComment() - mockTwistApi.comments.createComment.mockResolvedValue(mockComment) + mockCommsApi.comments.createComment.mockResolvedValue(mockComment) const result = await reply.execute( { @@ -137,10 +137,10 @@ describe(`${REPLY} tool`, () => { content: 'Notifying default recipients', groups: [], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.createComment).toHaveBeenCalledWith({ + expect(mockCommsApi.comments.createComment).toHaveBeenCalledWith({ threadId: TEST_IDS.THREAD_1, content: 'Notifying default recipients', recipients: undefined, @@ -155,7 +155,7 @@ describe(`${REPLY} tool`, () => { it('should post a comment with recipients and groups', async () => { const mockComment = createMockComment() - mockTwistApi.comments.createComment.mockResolvedValue(mockComment) + mockCommsApi.comments.createComment.mockResolvedValue(mockComment) const result = await reply.execute( { @@ -165,10 +165,10 @@ describe(`${REPLY} tool`, () => { recipients: [TEST_IDS.USER_1], groups: [100], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.createComment).toHaveBeenCalledWith({ + expect(mockCommsApi.comments.createComment).toHaveBeenCalledWith({ threadId: TEST_IDS.THREAD_1, content: 'Notifying users and groups', recipients: [TEST_IDS.USER_1], @@ -184,7 +184,7 @@ describe(`${REPLY} tool`, () => { it('should pass through an explicit notifyAudience', async () => { const mockComment = createMockComment() - mockTwistApi.comments.createComment.mockResolvedValue(mockComment) + mockCommsApi.comments.createComment.mockResolvedValue(mockComment) const result = await reply.execute( { @@ -193,10 +193,10 @@ describe(`${REPLY} tool`, () => { content: 'Notifying the whole channel', notifyAudience: 'channel', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.createComment).toHaveBeenCalledWith({ + expect(mockCommsApi.comments.createComment).toHaveBeenCalledWith({ threadId: TEST_IDS.THREAD_1, content: 'Notifying the whole channel', recipients: undefined, @@ -210,7 +210,7 @@ describe(`${REPLY} tool`, () => { it('should combine an explicit notifyAudience with recipients and groups', async () => { const mockComment = createMockComment() - mockTwistApi.comments.createComment.mockResolvedValue(mockComment) + mockCommsApi.comments.createComment.mockResolvedValue(mockComment) const result = await reply.execute( { @@ -221,10 +221,10 @@ describe(`${REPLY} tool`, () => { groups: [100], notifyAudience: 'channel', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.createComment).toHaveBeenCalledWith({ + expect(mockCommsApi.comments.createComment).toHaveBeenCalledWith({ threadId: TEST_IDS.THREAD_1, content: 'Notifying users, groups, and the whole channel', recipients: [TEST_IDS.USER_1], @@ -240,7 +240,7 @@ describe(`${REPLY} tool`, () => { it('should treat an explicit empty recipients array as user-provided and skip the default audience', async () => { const mockComment = createMockComment() - mockTwistApi.comments.createComment.mockResolvedValue(mockComment) + mockCommsApi.comments.createComment.mockResolvedValue(mockComment) const result = await reply.execute( { @@ -249,10 +249,10 @@ describe(`${REPLY} tool`, () => { content: 'No one to notify explicitly', recipients: [], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.createComment).toHaveBeenCalledWith({ + expect(mockCommsApi.comments.createComment).toHaveBeenCalledWith({ threadId: TEST_IDS.THREAD_1, content: 'No one to notify explicitly', recipients: [], @@ -270,7 +270,7 @@ describe(`${REPLY} tool`, () => { describe('replying to conversations', () => { it('should post a message to a conversation', async () => { const mockMessage = createMockConversationMessage() - mockTwistApi.conversationMessages.createMessage.mockResolvedValue(mockMessage) + mockCommsApi.conversationMessages.createMessage.mockResolvedValue(mockMessage) const result = await reply.execute( { @@ -278,10 +278,10 @@ describe(`${REPLY} tool`, () => { targetId: TEST_IDS.CONVERSATION_1, content: 'This is my message', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.conversationMessages.createMessage).toHaveBeenCalledWith({ + expect(mockCommsApi.conversationMessages.createMessage).toHaveBeenCalledWith({ conversationId: TEST_IDS.CONVERSATION_1, content: 'This is my message', }) @@ -298,11 +298,11 @@ describe(`${REPLY} tool`, () => { content: 'This is my message', groups: [100], }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('groups can only be used when replying to a thread.') - expect(mockTwistApi.conversationMessages.createMessage).not.toHaveBeenCalled() + expect(mockCommsApi.conversationMessages.createMessage).not.toHaveBeenCalled() }) it('should reject notifyAudience for conversation messages', async () => { @@ -314,11 +314,11 @@ describe(`${REPLY} tool`, () => { content: 'This is my message', notifyAudience: 'channel', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('notifyAudience can only be used when replying to a thread.') - expect(mockTwistApi.conversationMessages.createMessage).not.toHaveBeenCalled() + expect(mockCommsApi.conversationMessages.createMessage).not.toHaveBeenCalled() }) it('should reject empty groups for conversation messages', async () => { @@ -330,16 +330,16 @@ describe(`${REPLY} tool`, () => { content: 'This is my message', groups: [], }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('groups can only be used when replying to a thread.') - expect(mockTwistApi.conversationMessages.createMessage).not.toHaveBeenCalled() + expect(mockCommsApi.conversationMessages.createMessage).not.toHaveBeenCalled() }) it('should ignore recipients for conversation messages', async () => { const mockMessage = createMockConversationMessage() - mockTwistApi.conversationMessages.createMessage.mockResolvedValue(mockMessage) + mockCommsApi.conversationMessages.createMessage.mockResolvedValue(mockMessage) const result = await reply.execute( { @@ -348,10 +348,10 @@ describe(`${REPLY} tool`, () => { content: 'This is my message', recipients: [TEST_IDS.USER_1], }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.conversationMessages.createMessage).toHaveBeenCalledWith({ + expect(mockCommsApi.conversationMessages.createMessage).toHaveBeenCalledWith({ conversationId: TEST_IDS.CONVERSATION_1, content: 'This is my message', }) @@ -364,7 +364,7 @@ describe(`${REPLY} tool`, () => { describe('error handling', () => { it('should propagate thread reply errors', async () => { const apiError = new Error('Thread not found') - mockTwistApi.comments.createComment.mockRejectedValue(apiError) + mockCommsApi.comments.createComment.mockRejectedValue(apiError) await expect( reply.execute( @@ -373,14 +373,14 @@ describe(`${REPLY} tool`, () => { targetId: TEST_IDS.THREAD_1, content: 'Reply content', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Thread not found') }) it('should propagate conversation reply errors', async () => { const apiError = new Error('Conversation not found') - mockTwistApi.conversationMessages.createMessage.mockRejectedValue(apiError) + mockCommsApi.conversationMessages.createMessage.mockRejectedValue(apiError) await expect( reply.execute( @@ -389,7 +389,7 @@ describe(`${REPLY} tool`, () => { targetId: TEST_IDS.CONVERSATION_1, content: 'Message content', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Conversation not found') }) diff --git a/src/tools/__tests__/search-content.test.ts b/src/tools/__tests__/search-content.test.ts index b8957a6..cb9e915 100644 --- a/src/tools/__tests__/search-content.test.ts +++ b/src/tools/__tests__/search-content.test.ts @@ -1,11 +1,11 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { extractTextContent, TEST_IDS } from '../../utils/test-helpers.js' import { ToolNames } from '../../utils/tool-names.js' import { searchContent } from '../search-content.js' -// Mock the Twist API -const mockTwistApi = { +// Mock the Comms API +const mockCommsApi = { batch: jest.fn(), search: { search: jest.fn(), @@ -16,7 +16,7 @@ const mockTwistApi = { workspaceUsers: { getUserById: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { SEARCH_CONTENT } = ToolNames @@ -24,7 +24,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { beforeEach(() => { jest.clearAllMocks() // Mock batch to return responses with .data property - mockTwistApi.batch.mockImplementation(async (...args: readonly unknown[]) => { + mockCommsApi.batch.mockImplementation(async (...args: readonly unknown[]) => { const results = [] for (const arg of args) { const result = await arg @@ -36,7 +36,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { describe('workspace search', () => { it('should search across workspace with results', async () => { - mockTwistApi.search.search.mockResolvedValue({ + mockCommsApi.search.search.mockResolvedValue({ items: [ { id: 'thread-123', @@ -64,18 +64,17 @@ describe(`${SEARCH_CONTENT} tool`, () => { hasMore: false, isPlanRestricted: false, }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ id: TEST_IDS.USER_1, - name: 'Test User 1', + fullName: 'Test User 1', shortName: 'TU1', email: 'user1@test.com', userType: 'USER' as const, - bot: false, removed: false, timezone: 'UTC', version: 1, - }) - mockTwistApi.channels.getChannel.mockResolvedValue({ + } as never) + mockCommsApi.channels.getChannel.mockResolvedValue({ id: TEST_IDS.CHANNEL_1, name: 'Test Channel', workspaceId: TEST_IDS.WORKSPACE_1, @@ -93,10 +92,10 @@ describe(`${SEARCH_CONTENT} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.search.search).toHaveBeenCalledWith( + expect(mockCommsApi.search.search).toHaveBeenCalledWith( expect.objectContaining({ query: 'test query', workspaceId: TEST_IDS.WORKSPACE_1, @@ -135,7 +134,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { }) it('should search with filters', async () => { - mockTwistApi.search.search.mockResolvedValue({ + mockCommsApi.search.search.mockResolvedValue({ items: [ { id: 'thread-789', @@ -150,18 +149,17 @@ describe(`${SEARCH_CONTENT} tool`, () => { hasMore: false, isPlanRestricted: false, }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ id: TEST_IDS.USER_1, - name: 'Test User 1', + fullName: 'Test User 1', shortName: 'TU1', email: 'user1@test.com', userType: 'USER' as const, - bot: false, removed: false, timezone: 'UTC', version: 1, - }) - mockTwistApi.channels.getChannel.mockResolvedValue({ + } as never) + mockCommsApi.channels.getChannel.mockResolvedValue({ id: TEST_IDS.CHANNEL_1, name: 'Test Channel', workspaceId: TEST_IDS.WORKSPACE_1, @@ -184,10 +182,10 @@ describe(`${SEARCH_CONTENT} tool`, () => { dateTo: '2024-12-31', limit: 25, }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.search.search).toHaveBeenCalledWith({ + expect(mockCommsApi.search.search).toHaveBeenCalledWith({ query: 'filtered', workspaceId: TEST_IDS.WORKSPACE_1, channelIds: [TEST_IDS.CHANNEL_1], @@ -203,7 +201,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { }) it('should handle pagination', async () => { - mockTwistApi.search.search.mockResolvedValue({ + mockCommsApi.search.search.mockResolvedValue({ items: [ { id: 'result-1', @@ -218,17 +216,16 @@ describe(`${SEARCH_CONTENT} tool`, () => { nextCursorMark: 'next-cursor-123', isPlanRestricted: false, }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ id: TEST_IDS.USER_1, - name: 'Test User 1', + fullName: 'Test User 1', shortName: 'TU1', email: 'user1@test.com', userType: 'USER' as const, - bot: false, removed: false, timezone: 'UTC', version: 1, - }) + } as never) const result = await searchContent.execute( { @@ -236,7 +233,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, limit: 10, }, - mockTwistApi, + mockCommsApi, ) const { structuredContent } = result @@ -249,7 +246,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { describe('conversation results', () => { it('should handle conversation type results with correct URL', async () => { - mockTwistApi.search.search.mockResolvedValue({ + mockCommsApi.search.search.mockResolvedValue({ items: [ { id: 'conversation-33333', @@ -263,17 +260,16 @@ describe(`${SEARCH_CONTENT} tool`, () => { hasMore: false, isPlanRestricted: false, }) - mockTwistApi.workspaceUsers.getUserById.mockResolvedValue({ + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ id: TEST_IDS.USER_1, - name: 'Test User 1', + fullName: 'Test User 1', shortName: 'TU1', email: 'user1@test.com', userType: 'USER' as const, - bot: false, removed: false, timezone: 'UTC', version: 1, - }) + } as never) const result = await searchContent.execute( { @@ -281,7 +277,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, }, - mockTwistApi, + mockCommsApi, ) const { structuredContent } = result @@ -300,7 +296,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { describe('empty results', () => { it('should handle no results found', async () => { - mockTwistApi.search.search.mockResolvedValue({ + mockCommsApi.search.search.mockResolvedValue({ items: [], hasMore: false, isPlanRestricted: false, @@ -312,7 +308,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, }, - mockTwistApi, + mockCommsApi, ) const textContent = extractTextContent(result) @@ -323,7 +319,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { describe('error handling', () => { it('should propagate API errors', async () => { - mockTwistApi.search.search.mockRejectedValue(new Error('Search API error')) + mockCommsApi.search.search.mockRejectedValue(new Error('Search API error')) await expect( searchContent.execute( @@ -332,7 +328,7 @@ describe(`${SEARCH_CONTENT} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Search API error') }) diff --git a/src/tools/__tests__/tool-annotations.test.ts b/src/tools/__tests__/tool-annotations.test.ts index 91d5cc8..a41e042 100644 --- a/src/tools/__tests__/tool-annotations.test.ts +++ b/src/tools/__tests__/tool-annotations.test.ts @@ -12,128 +12,121 @@ type ToolExpectation = { } const TOOL_EXPECTATIONS: ToolExpectation[] = [ - { - name: ToolNames.AWAY, - title: 'Twist: Away', - readOnlyHint: false, - destructiveHint: false, - idempotentHint: true, - }, { name: ToolNames.USER_INFO, - title: 'Twist: User Info', + title: 'Comms: User Info', readOnlyHint: true, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.FETCH_INBOX, - title: 'Twist: Fetch Inbox', + title: 'Comms: Fetch Inbox', readOnlyHint: true, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.LOAD_THREAD, - title: 'Twist: Load Thread', + title: 'Comms: Load Thread', readOnlyHint: true, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.LOAD_CONVERSATION, - title: 'Twist: Load Conversation', + title: 'Comms: Load Conversation', readOnlyHint: true, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.SEARCH_CONTENT, - title: 'Twist: Search Content', + title: 'Comms: Search Content', readOnlyHint: true, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.GET_MENTIONS, - title: 'Twist: Get Mentions', + title: 'Comms: Get Mentions', readOnlyHint: true, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.GET_USERS, - title: 'Twist: Get Users', + title: 'Comms: Get Users', readOnlyHint: true, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.GET_GROUPS, - title: 'Twist: Get Groups', + title: 'Comms: Get Groups', readOnlyHint: true, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.GET_WORKSPACES, - title: 'Twist: Get Workspaces', + title: 'Comms: Get Workspaces', readOnlyHint: true, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.BUILD_LINK, - title: 'Twist: Build Link', + title: 'Comms: Build Link', readOnlyHint: true, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.CREATE_THREAD, - title: 'Twist: Create Thread', + title: 'Comms: Create Thread', readOnlyHint: false, destructiveHint: false, idempotentHint: false, }, { name: ToolNames.UPDATE_OBJECT, - title: 'Twist: Update Object', + title: 'Comms: Update Object', readOnlyHint: false, destructiveHint: false, idempotentHint: true, }, { name: ToolNames.DELETE_OBJECT, - title: 'Twist: Delete Object', + title: 'Comms: Delete Object', readOnlyHint: false, destructiveHint: true, idempotentHint: true, }, { name: ToolNames.REPLY, - title: 'Twist: Reply', + title: 'Comms: Reply', readOnlyHint: false, destructiveHint: false, idempotentHint: false, }, { name: ToolNames.REACT, - title: 'Twist: React', + title: 'Comms: React', readOnlyHint: false, destructiveHint: true, idempotentHint: false, }, { name: ToolNames.MARK_DONE, - title: 'Twist: Mark Done', + title: 'Comms: Mark Done', readOnlyHint: false, destructiveHint: true, idempotentHint: true, }, { name: ToolNames.LIST_CHANNELS, - title: 'Twist: List Channels', + title: 'Comms: List Channels', readOnlyHint: true, destructiveHint: false, idempotentHint: true, @@ -145,7 +138,7 @@ describe('Tool annotations', () => { beforeAll(() => { const registerToolSpy = jest.spyOn(McpServer.prototype, 'registerTool') - getMcpServer({ twistApiKey: 'test-token' }) + getMcpServer({ commsApiKey: 'test-token' }) const calls = registerToolSpy.mock.calls as unknown as unknown[][] for (const [name, toolSpec] of calls) { diff --git a/src/tools/__tests__/update-object.test.ts b/src/tools/__tests__/update-object.test.ts index 00b876c..e410512 100644 --- a/src/tools/__tests__/update-object.test.ts +++ b/src/tools/__tests__/update-object.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { createMockComment, @@ -10,7 +10,7 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { updateObject } from '../update-object.js' -const mockTwistApi = { +const mockCommsApi = { threads: { updateThread: jest.fn(), }, @@ -20,7 +20,7 @@ const mockTwistApi = { conversationMessages: { updateMessage: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { UPDATE_OBJECT } = ToolNames @@ -36,7 +36,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { content: 'Updated content', lastEdited: new Date('2025-02-03T12:34:56Z'), }) - mockTwistApi.threads.updateThread.mockResolvedValue(mockThread) + mockCommsApi.threads.updateThread.mockResolvedValue(mockThread) const result = await updateObject.execute( { @@ -45,10 +45,10 @@ describe(`${UPDATE_OBJECT} tool`, () => { title: 'Updated Title', content: 'Updated content', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.updateThread).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.updateThread).toHaveBeenCalledWith({ id: TEST_IDS.THREAD_1, title: 'Updated Title', content: 'Updated content', @@ -66,7 +66,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { channelId: TEST_IDS.CHANNEL_1, workspaceId: TEST_IDS.WORKSPACE_1, content: 'Updated content', - threadUrl: expect.stringContaining('twist.com'), + threadUrl: expect.stringContaining('comms.todoist.com'), lastEdited: '2025-02-03T12:34:56.000Z', }), ) @@ -74,7 +74,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { it('should update only the thread title', async () => { const mockThread = createMockThread({ title: 'New Title Only' }) - mockTwistApi.threads.updateThread.mockResolvedValue(mockThread) + mockCommsApi.threads.updateThread.mockResolvedValue(mockThread) const result = await updateObject.execute( { @@ -82,10 +82,10 @@ describe(`${UPDATE_OBJECT} tool`, () => { targetId: TEST_IDS.THREAD_1, title: 'New Title Only', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.updateThread).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.updateThread).toHaveBeenCalledWith({ id: TEST_IDS.THREAD_1, title: 'New Title Only', content: undefined, @@ -107,7 +107,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { it('should update only the thread content', async () => { const mockThread = createMockThread({ content: 'New content only' }) - mockTwistApi.threads.updateThread.mockResolvedValue(mockThread) + mockCommsApi.threads.updateThread.mockResolvedValue(mockThread) const result = await updateObject.execute( { @@ -115,10 +115,10 @@ describe(`${UPDATE_OBJECT} tool`, () => { targetId: TEST_IDS.THREAD_1, content: 'New content only', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.threads.updateThread).toHaveBeenCalledWith({ + expect(mockCommsApi.threads.updateThread).toHaveBeenCalledWith({ id: TEST_IDS.THREAD_1, title: undefined, content: 'New content only', @@ -131,15 +131,15 @@ describe(`${UPDATE_OBJECT} tool`, () => { await expect( updateObject.execute( { targetType: 'thread', targetId: TEST_IDS.THREAD_1 }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('At least one of `title` or `content` must be provided.') - expect(mockTwistApi.threads.updateThread).not.toHaveBeenCalled() + expect(mockCommsApi.threads.updateThread).not.toHaveBeenCalled() }) it('should propagate API errors', async () => { - mockTwistApi.threads.updateThread.mockRejectedValue(new Error('Thread not found')) + mockCommsApi.threads.updateThread.mockRejectedValue(new Error('Thread not found')) await expect( updateObject.execute( @@ -148,7 +148,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { targetId: TEST_IDS.THREAD_1, title: 'Updated Title', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Thread not found') }) @@ -160,7 +160,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { content: 'Updated comment content', lastEdited: new Date('2025-02-03T12:34:56Z'), }) - mockTwistApi.comments.updateComment.mockResolvedValue(mockComment) + mockCommsApi.comments.updateComment.mockResolvedValue(mockComment) const result = await updateObject.execute( { @@ -168,10 +168,10 @@ describe(`${UPDATE_OBJECT} tool`, () => { targetId: TEST_IDS.COMMENT_1, content: 'Updated comment content', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.comments.updateComment).toHaveBeenCalledWith({ + expect(mockCommsApi.comments.updateComment).toHaveBeenCalledWith({ id: TEST_IDS.COMMENT_1, content: 'Updated comment content', }) @@ -188,7 +188,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { channelId: mockComment.channelId, workspaceId: mockComment.workspaceId, content: 'Updated comment content', - commentUrl: expect.stringContaining('twist.com'), + commentUrl: expect.stringContaining('comms.todoist.com'), lastEdited: '2025-02-03T12:34:56.000Z', }), ) @@ -198,15 +198,15 @@ describe(`${UPDATE_OBJECT} tool`, () => { await expect( updateObject.execute( { targetType: 'comment', targetId: TEST_IDS.COMMENT_1 }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('`content` is required when targetType is "comment".') - expect(mockTwistApi.comments.updateComment).not.toHaveBeenCalled() + expect(mockCommsApi.comments.updateComment).not.toHaveBeenCalled() }) it('should propagate API errors', async () => { - mockTwistApi.comments.updateComment.mockRejectedValue(new Error('Comment not found')) + mockCommsApi.comments.updateComment.mockRejectedValue(new Error('Comment not found')) await expect( updateObject.execute( @@ -215,7 +215,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { targetId: TEST_IDS.COMMENT_1, content: 'Updated content', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Comment not found') }) @@ -227,7 +227,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { content: 'Updated message content', lastEdited: new Date('2025-02-03T12:34:56Z'), }) - mockTwistApi.conversationMessages.updateMessage.mockResolvedValue(mockMessage) + mockCommsApi.conversationMessages.updateMessage.mockResolvedValue(mockMessage) const result = await updateObject.execute( { @@ -235,10 +235,10 @@ describe(`${UPDATE_OBJECT} tool`, () => { targetId: TEST_IDS.MESSAGE_1, content: 'Updated message content', }, - mockTwistApi, + mockCommsApi, ) - expect(mockTwistApi.conversationMessages.updateMessage).toHaveBeenCalledWith({ + expect(mockCommsApi.conversationMessages.updateMessage).toHaveBeenCalledWith({ id: TEST_IDS.MESSAGE_1, content: 'Updated message content', }) @@ -254,7 +254,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { conversationId: mockMessage.conversationId, workspaceId: mockMessage.workspaceId, content: 'Updated message content', - messageUrl: expect.stringContaining('twist.com'), + messageUrl: expect.stringContaining('comms.todoist.com'), lastEdited: '2025-02-03T12:34:56.000Z', }), ) @@ -265,7 +265,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { content: 'Edited again', lastEdited: null, }) - mockTwistApi.conversationMessages.updateMessage.mockResolvedValue(mockMessage) + mockCommsApi.conversationMessages.updateMessage.mockResolvedValue(mockMessage) const result = await updateObject.execute( { @@ -273,7 +273,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { targetId: TEST_IDS.MESSAGE_1, content: 'Edited again', }, - mockTwistApi, + mockCommsApi, ) const { structuredContent } = result @@ -290,15 +290,15 @@ describe(`${UPDATE_OBJECT} tool`, () => { await expect( updateObject.execute( { targetType: 'message', targetId: TEST_IDS.MESSAGE_1 }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('`content` is required when targetType is "message".') - expect(mockTwistApi.conversationMessages.updateMessage).not.toHaveBeenCalled() + expect(mockCommsApi.conversationMessages.updateMessage).not.toHaveBeenCalled() }) it('should propagate API errors', async () => { - mockTwistApi.conversationMessages.updateMessage.mockRejectedValue( + mockCommsApi.conversationMessages.updateMessage.mockRejectedValue( new Error('Message not found'), ) @@ -309,7 +309,7 @@ describe(`${UPDATE_OBJECT} tool`, () => { targetId: TEST_IDS.MESSAGE_1, content: 'Updated content', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('Message not found') }) @@ -325,11 +325,11 @@ describe(`${UPDATE_OBJECT} tool`, () => { content: 'updated', title: 'oops', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('`title` is only valid when targetType is "thread".') - expect(mockTwistApi.comments.updateComment).not.toHaveBeenCalled() + expect(mockCommsApi.comments.updateComment).not.toHaveBeenCalled() }) it('should reject title for message targetType', async () => { @@ -341,11 +341,11 @@ describe(`${UPDATE_OBJECT} tool`, () => { content: 'updated', title: 'oops', }, - mockTwistApi, + mockCommsApi, ), ).rejects.toThrow('`title` is only valid when targetType is "thread".') - expect(mockTwistApi.conversationMessages.updateMessage).not.toHaveBeenCalled() + expect(mockCommsApi.conversationMessages.updateMessage).not.toHaveBeenCalled() }) }) }) diff --git a/src/tools/__tests__/user-info.test.ts b/src/tools/__tests__/user-info.test.ts index b7a4761..830396f 100644 --- a/src/tools/__tests__/user-info.test.ts +++ b/src/tools/__tests__/user-info.test.ts @@ -1,4 +1,4 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { jest } from '@jest/globals' import { createMockUser, @@ -10,12 +10,12 @@ import { import { ToolNames } from '../../utils/tool-names.js' import { userInfo } from '../user-info.js' -// Mock the Twist API -const mockTwistApi = { +// Mock the Comms API +const mockCommsApi = { users: { getSessionUser: jest.fn(), }, -} as unknown as jest.Mocked +} as unknown as jest.Mocked const { USER_INFO } = ToolNames @@ -27,11 +27,11 @@ describe(`${USER_INFO} tool`, () => { it('should generate user info with all required fields', async () => { const mockUser = createMockUser() - mockTwistApi.users.getSessionUser.mockResolvedValue(mockUser) + mockCommsApi.users.getSessionUser.mockResolvedValue(mockUser) - const result = await userInfo.execute({}, mockTwistApi) + const result = await userInfo.execute({}, mockCommsApi) - expect(mockTwistApi.users.getSessionUser).toHaveBeenCalledWith() + expect(mockCommsApi.users.getSessionUser).toHaveBeenCalledWith() // Test text content contains expected information const textContent = extractTextContent(result) @@ -39,8 +39,6 @@ describe(`${USER_INFO} tool`, () => { expect(textContent).toContain('Test User') expect(textContent).toContain('test@example.com') expect(textContent).toContain('UTC') - expect(textContent).toContain(`Default Workspace:** ${TEST_IDS.WORKSPACE_1}`) - expect(textContent).toContain('**Bot:** No') expect(textContent).toContain('**Language:** en') // Test structured content @@ -51,9 +49,9 @@ describe(`${USER_INFO} tool`, () => { userId: TEST_IDS.USER_1, email: 'test@example.com', name: 'Test User', + shortName: 'Test', timezone: 'UTC', - defaultWorkspace: TEST_IDS.WORKSPACE_1, - bot: false, + lang: 'en', }), ) }) @@ -63,9 +61,9 @@ describe(`${USER_INFO} tool`, () => { timezone: 'America/New_York', }) - mockTwistApi.users.getSessionUser.mockResolvedValue(mockUser) + mockCommsApi.users.getSessionUser.mockResolvedValue(mockUser) - const result = await userInfo.execute({}, mockTwistApi) + const result = await userInfo.execute({}, mockCommsApi) const textContent = extractTextContent(result) expect(textContent).toContain('America/New_York') @@ -74,24 +72,11 @@ describe(`${USER_INFO} tool`, () => { expect(structuredContent.timezone).toBe('America/New_York') }) - it('should handle users without profession', async () => { - const mockUser = createMockUser({ - profession: undefined, - }) - - mockTwistApi.users.getSessionUser.mockResolvedValue(mockUser) - - const result = await userInfo.execute({}, mockTwistApi) - - const textContent = extractTextContent(result) - expect(textContent).not.toContain('Profession') - }) - it('should propagate API errors', async () => { const apiError = new Error(TEST_ERRORS.API_UNAUTHORIZED) - mockTwistApi.users.getSessionUser.mockRejectedValue(apiError) + mockCommsApi.users.getSessionUser.mockRejectedValue(apiError) - await expect(userInfo.execute({}, mockTwistApi)).rejects.toThrow( + await expect(userInfo.execute({}, mockCommsApi)).rejects.toThrow( TEST_ERRORS.API_UNAUTHORIZED, ) }) diff --git a/src/tools/away.ts b/src/tools/away.ts deleted file mode 100644 index 19363a3..0000000 --- a/src/tools/away.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { AWAY_MODE_TYPES, type AwayModeType, type TwistApi } from '@doist/twist-sdk' -import { z } from 'zod' -import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' -import { AWAY_ACTIONS, type AwayOutput, AwayOutputSchema } from '../utils/output-schemas.js' -import { ToolNames } from '../utils/tool-names.js' - -const ArgsSchema = { - action: z.enum(AWAY_ACTIONS).describe('The action to perform.'), - type: z - .enum(AWAY_MODE_TYPES) - .optional() - .describe('The away mode type. Required when action is "set".'), - from: z - .string() - .optional() - .describe('Start date (YYYY-MM-DD). Only used when action is "set". Defaults to today.'), - until: z.string().optional().describe('End date (YYYY-MM-DD). Required when action is "set".'), -} - -const AWAY_TYPE_LABELS: Record = { - vacation: 'Vacation', - parental: 'Parental leave', - sickleave: 'Sick leave', - other: 'Away', -} - -function formatAwayType(type: AwayModeType): string { - return AWAY_TYPE_LABELS[type] -} - -function getTodayDate(): string { - const now = new Date() - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -async function executeGet( - client: TwistApi, -): Promise<{ textContent: string; structuredContent: AwayOutput }> { - const user = await client.users.getSessionUser() - const awayMode = user.awayMode ?? undefined - - const lines: string[] = ['# Away Status'] - if (awayMode) { - lines.push( - '', - `**Status:** Away`, - `**Type:** ${formatAwayType(awayMode.type as AwayModeType)}`, - `**From:** ${awayMode.dateFrom}`, - `**To:** ${awayMode.dateTo}`, - ) - } else { - lines.push('', '**Status:** Not away') - } - - return { - textContent: lines.join('\n'), - structuredContent: { - type: 'away_status', - action: 'get', - isAway: awayMode !== undefined, - awayMode: awayMode - ? { - type: awayMode.type, - dateFrom: awayMode.dateFrom, - dateTo: awayMode.dateTo, - } - : undefined, - }, - } -} - -async function executeSet( - client: TwistApi, - awayType: AwayModeType, - dateFrom: string, - dateTo: string, -): Promise<{ textContent: string; structuredContent: AwayOutput }> { - await client.users.update({ - awayMode: { type: awayType, dateFrom, dateTo }, - }) - - return { - textContent: [ - '# Away Status Set', - '', - `**Type:** ${formatAwayType(awayType)}`, - `**From:** ${dateFrom}`, - `**To:** ${dateTo}`, - ].join('\n'), - structuredContent: { - type: 'away_status', - action: 'set', - isAway: true, - awayMode: { - type: awayType, - dateFrom, - dateTo, - }, - }, - } -} - -async function executeClear( - client: TwistApi, -): Promise<{ textContent: string; structuredContent: AwayOutput }> { - await client.users.update({ awayMode: '' as never }) - - return { - textContent: '# Away Status Cleared\n\n**Status:** Not away', - structuredContent: { - type: 'away_status', - action: 'clear', - isAway: false, - awayMode: undefined, - }, - } -} - -const away = { - name: ToolNames.AWAY, - description: - "Manage the current user's away status. Supports getting, setting, and clearing away mode with types: parental, vacation, sickleave, other.", - parameters: ArgsSchema, - outputSchema: AwayOutputSchema.shape, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: true, - }, - async execute(args, client) { - switch (args.action) { - case 'get': { - const result = await executeGet(client) - return getToolOutput(result) - } - case 'set': { - if (!args.type) { - throw new Error('The "type" parameter is required when action is "set".') - } - if (!args.until) { - throw new Error('The "until" parameter is required when action is "set".') - } - const dateFrom = args.from ?? getTodayDate() - const result = await executeSet(client, args.type, dateFrom, args.until) - return getToolOutput(result) - } - case 'clear': { - const result = await executeClear(client) - return getToolOutput(result) - } - } - }, -} satisfies TwistTool - -export { away } diff --git a/src/tools/build-link.ts b/src/tools/build-link.ts index 645b7a3..7be68e2 100644 --- a/src/tools/build-link.ts +++ b/src/tools/build-link.ts @@ -1,33 +1,33 @@ -import { getCommentURL, getFullTwistURL, getMessageURL } from '@doist/twist-sdk' +import { getCommentURL, getFullCommsURL, getMessageURL } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { BuildLinkOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' const ArgsSchema = { workspaceId: z.number().describe('The workspace ID.'), conversationId: z - .number() + .string() .optional() .describe('The conversation ID (for direct message links).'), messageId: z - .number() - .or(z.string()) + .string() .optional() .describe('The message ID (for specific message links within a conversation).'), - channelId: z.number().optional().describe('The channel ID (for thread links in channels).'), - threadId: z.number().optional().describe('The thread ID (for thread/comment links).'), + channelId: z.string().optional().describe('The channel ID (for thread links in channels).'), + threadId: z.string().optional().describe('The thread ID (for thread/comment links).'), commentId: z - .number() - .or(z.string()) + .string() .optional() .describe('The comment ID (for specific comment links within a thread).'), fullUrl: z .boolean() .optional() .default(true) - .describe('Whether to return a full URL (with https://twist.com) or relative path.'), + .describe( + 'Whether to return a full URL (with https://comms.todoist.com) or relative path.', + ), } type BuildLinkStructured = { @@ -36,18 +36,18 @@ type BuildLinkStructured = { linkType: 'conversation' | 'message' | 'thread' | 'comment' params: { workspaceId: number - conversationId?: number - messageId?: number | string - channelId?: number - threadId?: number - commentId?: number | string + conversationId?: string + messageId?: string + channelId?: string + threadId?: string + commentId?: string } } const buildLink = { name: ToolNames.BUILD_LINK, description: - 'Build valid Twist URLs for threads, comments, conversations, or messages. Provide workspace_id and either (conversation_id + optional message_id) OR (thread_id + optional channel_id + optional comment_id).', + 'Build valid Comms URLs for threads, comments, conversations, or messages. Provide workspace_id and either (conversation_id + optional message_id) OR (thread_id + optional channel_id + optional comment_id).', parameters: ArgsSchema, outputSchema: BuildLinkOutputSchema.shape, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, @@ -64,14 +64,14 @@ const buildLink = { // Message link linkType = 'message' const params = { workspaceId, conversationId, messageId } - url = fullUrl ? getFullTwistURL(params) : getMessageURL(params) + url = fullUrl ? getFullCommsURL(params) : getMessageURL(params) } else { // Conversation link linkType = 'conversation' const params = { workspaceId, conversationId } url = fullUrl - ? getFullTwistURL(params) - : getFullTwistURL(params).replace('https://twist.com', '') + ? getFullCommsURL(params) + : getFullCommsURL(params).replace('https://comms.todoist.com', '') } } else if (threadId !== undefined) { if (commentId !== undefined) { @@ -81,7 +81,7 @@ const buildLink = { throw new Error('channelId is required when building a comment link') } const params = { workspaceId, channelId, threadId, commentId } - url = fullUrl ? getFullTwistURL(params) : getCommentURL(params) + url = fullUrl ? getFullCommsURL(params) : getCommentURL(params) } else { // Thread link linkType = 'thread' @@ -89,8 +89,8 @@ const buildLink = { ? { workspaceId, channelId, threadId } : { workspaceId, threadId } url = fullUrl - ? getFullTwistURL(params) - : getFullTwistURL(params).replace('https://twist.com', '') + ? getFullCommsURL(params) + : getFullCommsURL(params).replace('https://comms.todoist.com', '') } } else { throw new Error('Must provide either conversationId OR threadId to build a link') @@ -115,6 +115,6 @@ const buildLink = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { buildLink, type BuildLinkStructured } diff --git a/src/tools/create-thread.ts b/src/tools/create-thread.ts index 4c86c21..3ecf882 100644 --- a/src/tools/create-thread.ts +++ b/src/tools/create-thread.ts @@ -1,22 +1,22 @@ -import { getFullTwistURL } from '@doist/twist-sdk' +import { getFullCommsURL } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { type CreateThreadOutput, CreateThreadOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' const ArgsSchema = { - channelId: z.number().describe('The ID of the channel to create the thread in.'), + channelId: z.string().describe('The ID of the channel to create the thread in.'), title: z.string().min(1).describe('The title of the thread.'), content: z.string().min(1).describe('The content/body of the thread.'), recipients: z .array(z.number()) .optional() .describe( - 'Optional array of user IDs to notify. If omitted, Twist defaults to notifying all current members of the channel (equivalent to the API\'s "EVERYONE" default). Note: workspace users who have not joined this channel will not be notified — add their IDs explicitly if you want to reach them.', + 'Optional array of user IDs to notify. If omitted, Comms defaults to notifying all current members of the channel (equivalent to the API\'s "EVERYONE" default). Note: workspace users who have not joined this channel will not be notified — add their IDs explicitly if you want to reach them.', ), groups: z - .array(z.number()) + .array(z.string()) .optional() .describe( 'Optional array of group IDs to notify. Use get-groups to discover group IDs before passing them here.', @@ -50,7 +50,7 @@ const createThread = { const threadUrl = thread.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: thread.workspaceId, channelId: thread.channelId, threadId: thread.id, @@ -92,6 +92,6 @@ const createThread = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { createThread } diff --git a/src/tools/delete-object.ts b/src/tools/delete-object.ts index 2d096ee..80d64d9 100644 --- a/src/tools/delete-object.ts +++ b/src/tools/delete-object.ts @@ -1,7 +1,7 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { type DeleteCommentOutput, type DeleteMessageOutput, @@ -17,14 +17,14 @@ const ArgsSchema = { 'The type of object to delete: thread, comment, or message.', ), targetId: z - .number() + .string() .describe('The ID of the thread, comment, or conversation message to delete.'), } type Args = z.infer> type Branch = { textContent: string; structuredContent: DeleteObjectStructured } -async function deleteThreadBranch(args: Args, client: TwistApi): Promise { +async function deleteThreadBranch(args: Args, client: CommsApi): Promise { const { targetId } = args await client.threads.deleteThread(targetId) @@ -47,7 +47,7 @@ async function deleteThreadBranch(args: Args, client: TwistApi): Promise return { textContent: lines.join('\n'), structuredContent } } -async function deleteCommentBranch(args: Args, client: TwistApi): Promise { +async function deleteCommentBranch(args: Args, client: CommsApi): Promise { const { targetId } = args await client.comments.deleteComment(targetId) @@ -70,7 +70,7 @@ async function deleteCommentBranch(args: Args, client: TwistApi): Promise { +async function deleteMessageBranch(args: Args, client: CommsApi): Promise { const { targetId } = args await client.conversationMessages.deleteMessage(targetId) @@ -96,7 +96,7 @@ async function deleteMessageBranch(args: Args, client: TwistApi): Promise +} satisfies CommsTool export { deleteObject } diff --git a/src/tools/fetch-inbox.ts b/src/tools/fetch-inbox.ts index bb2939a..12c5bf2 100644 --- a/src/tools/fetch-inbox.ts +++ b/src/tools/fetch-inbox.ts @@ -4,11 +4,12 @@ import type { InboxThread, UnreadConversation, WorkspaceUser, -} from '@doist/twist-sdk' -import { ARCHIVE_FILTER_VALUES, getFullTwistURL } from '@doist/twist-sdk' +} from '@doist/comms-sdk' +import { ARCHIVE_FILTER_VALUES, getFullCommsURL } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' +import { limitedAll } from '../utils/concurrency.js' import { FetchInboxOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' @@ -47,9 +48,9 @@ type FetchInboxStructured = { type: 'inbox_data' workspaceId: number threads: Array<{ - id: number + id: string title: string - channelId: number + channelId: string channelName?: string creator: number isUnread: boolean @@ -58,7 +59,7 @@ type FetchInboxStructured = { threadUrl: string }> conversations: Array<{ - id: number + id: string title: string userIds: number[] participantNames: string[] @@ -76,8 +77,8 @@ type FetchInboxStructured = { * Helper function to load conversation details with participant information */ async function loadConversationDetails( - client: Parameters['execute']>[1], - conversationIds: number[], + client: Parameters['execute']>[1], + conversationIds: string[], ): Promise< Array<{ conversation: Conversation @@ -88,12 +89,10 @@ async function loadConversationDetails( return [] } - // Batch load all conversation metadata - const conversationCalls = conversationIds.map((id) => - client.conversations.getConversation(id, { batch: true }), + // Load all conversation metadata in parallel + const conversations = await Promise.all( + conversationIds.map((id) => client.conversations.getConversation(id)), ) - const conversationResponses = await client.batch(...conversationCalls) - const conversations = conversationResponses.map((res) => res.data) // Collect all unique user IDs from all conversations const allUserIds = new Set() @@ -103,23 +102,21 @@ async function loadConversationDetails( } } - // Batch load all user info + // Load all user info in parallel const workspaceId = conversations[0]?.workspaceId if (!workspaceId) { return conversations.map((conversation) => ({ conversation, participants: [] })) } - const userCalls = Array.from(allUserIds).map((userId) => - client.workspaceUsers.getUserById({ workspaceId, userId }, { batch: true }), - ) - const userResponses = await client.batch(...userCalls) - const userMap = userResponses.reduce( - (acc, res) => { - acc[res.data.id] = res.data - return acc - }, - {} as Record, + const users = await Promise.all( + Array.from(allUserIds).map((userId) => + client.workspaceUsers.getUserById({ workspaceId, userId }), + ), ) + const userMap = users.reduce>((acc, user) => { + acc[user.id] = user + return acc + }, {}) // Map conversations to include their participants return conversations.map((conversation) => ({ @@ -142,46 +139,38 @@ const fetchInbox = { const archiveFilter = args.archiveFilter ?? 'active' // Call all 4 endpoints in parallel for complete inbox picture - const [ - inboxThreadsResponse, - unreadCountResponse, - unreadThreadsDataResponse, - unreadConversationsDataResponse, - ] = await client.batch( - client.inbox.getInbox( - { + const [inboxThreads, unreadCount, unreadThreadsData, unreadConversationsData] = + await Promise.all([ + client.inbox.getInbox({ workspaceId, - since: sinceDate ? new Date(sinceDate) : undefined, - until: untilDate ? new Date(untilDate) : undefined, + newerThan: sinceDate ? new Date(sinceDate) : undefined, + olderThan: untilDate ? new Date(untilDate) : undefined, limit, archiveFilter, - }, - { batch: true }, - ), - client.inbox.getCount(workspaceId, { batch: true }), - client.threads.getUnread(workspaceId, { batch: true }), - client.conversations.getUnread(workspaceId, { batch: true }), - ) + }), + client.inbox.getCount(workspaceId), + client.threads.getUnread(workspaceId), + client.conversations.getUnread(workspaceId), + ]) // Filter by unread if requested - let threads = inboxThreadsResponse.data.map((thread) => ({ + let threads = inboxThreads.map((thread) => ({ ...thread, - isUnread: unreadThreadsDataResponse.data.some((ut) => ut.threadId === thread.id), + isUnread: unreadThreadsData.data.some((ut) => ut.threadId === thread.id), isArchived: thread.isArchived, })) const unreadThreads = threads.filter((t) => t.isUnread) - const unreadThreadsOriginal = inboxThreadsResponse.data.filter((thread) => - unreadThreadsDataResponse.data.some((ut) => ut.threadId === thread.id), + const unreadThreadsOriginal = inboxThreads.filter((thread) => + unreadThreadsData.data.some((ut) => ut.threadId === thread.id), ) - const unreadCount = unreadCountResponse.data if (onlyUnread) { threads = unreadThreads } // Load unread conversations if any exist - const unreadConversationsOriginal = unreadConversationsDataResponse.data + const unreadConversationsOriginal = unreadConversationsData.data const unreadConversationIds = unreadConversationsOriginal.map((uc) => uc.conversationId) let conversationsWithDetails: Array<{ @@ -217,17 +206,19 @@ const fetchInbox = { lines.push(`## Threads (${threads.length})`) lines.push('') - const channelCalls = threads.map((thread) => - client.channels.getChannel(thread.channelId, { batch: true }), - ) - const channelResponses = await client.batch(...channelCalls) - const channelInfo: Record = channelResponses.reduce( - (acc, res) => { - acc[res.data.id] = res.data - return acc - }, - {} as Record, + // Dedupe channel IDs so we hit `channels.getChannel` once per channel + // even when the inbox page is dominated by threads from the same channel. + // `limitedAll` caps the burst on a large inbox page. + const uniqueChannelIds = Array.from(new Set(threads.map((t) => t.channelId))) + const channelResponses = await limitedAll(uniqueChannelIds, (channelId) => + client.channels.getChannel(channelId).catch(() => null), ) + const channelInfo: Record = channelResponses.reduce< + Record + >((acc, channel) => { + if (channel) acc[channel.id] = channel + return acc + }, {}) if (threads.length === 0) { lines.push('_No threads in inbox_') @@ -240,8 +231,8 @@ const fetchInbox = { thread.title = `[${channel.name}] ${thread.title}` } const archivedBadge = thread.isArchived ? ' [archived]' : '' - const unreadBadge = thread.isUnread ? ' 🔵' : '' - const starBadge = thread.starred ? ' ⭐' : '' + const unreadBadge = thread.isUnread ? ' [unread]' : '' + const starBadge = thread.isSaved ? ' [saved]' : '' lines.push( `- ${thread.title}${archivedBadge}${unreadBadge}${starBadge}${channelDetails} (ID: ${thread.id})`, ) @@ -257,12 +248,12 @@ const fetchInbox = { for (const convDetail of conversationsWithDetails) { const { conversation, participants } = convDetail // Build a human-readable title from participant names - const participantNames = participants.map((p) => p.name).join(', ') + const participantNames = participants.map((p) => p.fullName).join(', ') const conversationTitle = conversation.title || `DM with ${participantNames}` || `Conversation ${conversation.id}` - const unreadBadge = convDetail.isUnread ? ' 🔵' : '' + const unreadBadge = convDetail.isUnread ? ' [unread]' : '' lines.push(`- ${conversationTitle}${unreadBadge} (ID: ${conversation.id})`) } @@ -290,14 +281,14 @@ const fetchInbox = { creator: t.creator, isUnread: t.isUnread, isArchived: t.isArchived, - isStarred: t.starred, + isStarred: Boolean(t.isSaved), threadUrl: t.url ?? - getFullTwistURL({ workspaceId, channelId: t.channelId, threadId: t.id }), + getFullCommsURL({ workspaceId, channelId: t.channelId, threadId: t.id }), })), conversations: conversationsWithDetails.map((cd) => { const { conversation, participants } = cd - const participantNames = participants.map((p) => p.name) + const participantNames = participants.map((p) => p.fullName) return { id: conversation.id, title: @@ -309,7 +300,7 @@ const fetchInbox = { isUnread: cd.isUnread, conversationUrl: conversation.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: conversation.workspaceId, conversationId: conversation.id, }), @@ -327,6 +318,6 @@ const fetchInbox = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { fetchInbox, type FetchInboxStructured } diff --git a/src/tools/get-groups.ts b/src/tools/get-groups.ts index 7e9b601..6524470 100644 --- a/src/tools/get-groups.ts +++ b/src/tools/get-groups.ts @@ -1,13 +1,13 @@ import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { type GetGroupsOutput, GetGroupsOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' const ArgsSchema = { workspaceId: z.number().describe('The workspace ID to get groups from.'), groupIds: z - .array(z.number()) + .array(z.string()) .optional() .describe( 'Optional array of specific group IDs to fetch. If not provided or empty array, fetches all workspace groups.', @@ -33,15 +33,15 @@ const getGroups = { const requestedGroupIds = groupIds && groupIds.length > 0 ? [...new Set(groupIds)] : undefined const groups = requestedGroupIds - ? await (async () => { - const groupRequests = requestedGroupIds.map((groupId) => - client.groups.getGroup(groupId, { batch: true }), + ? ( + await Promise.all( + requestedGroupIds.map((id) => + client.groups.getGroup({ id, workspaceId }).catch(() => null), + ), ) - const groupResponses = await client.batch(...groupRequests) - return groupResponses - .map((response) => response.data) - .filter((group) => group.workspaceId === workspaceId) - })() + ) + .filter((g): g is NonNullable => g !== null) + .filter((group) => group.workspaceId === workspaceId) : await client.groups.getGroups(workspaceId) const totalGroups = groups.length @@ -92,6 +92,6 @@ const getGroups = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { getGroups, type GetGroupsStructured } diff --git a/src/tools/get-mentions.ts b/src/tools/get-mentions.ts index 60b8c46..928ef94 100644 --- a/src/tools/get-mentions.ts +++ b/src/tools/get-mentions.ts @@ -1,13 +1,13 @@ -import { type SearchResultType, getFullTwistURL } from '@doist/twist-sdk' +import { type SearchResultType, getFullCommsURL } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { GetMentionsOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' const ArgsSchema = { workspaceId: z.number().describe('The workspace ID to search in.'), - channelIds: z.array(z.number()).optional().describe('Filter by channel IDs.'), + channelIds: z.array(z.string()).optional().describe('Filter by channel IDs.'), authorIds: z.array(z.number()).optional().describe('Filter by author user IDs.'), dateFrom: z.string().optional().describe('Start date for filtering (YYYY-MM-DD).'), dateTo: z.string().optional().describe('End date for filtering (YYYY-MM-DD).'), @@ -32,9 +32,9 @@ type GetMentionsStructured = { creatorId: number creatorName?: string created: string - threadId?: number - conversationId?: number - channelId?: number + threadId?: string + conversationId?: string + channelId?: string channelName?: string workspaceId: number url: string @@ -81,47 +81,42 @@ const getMentions = { const responseCursor = response.nextCursorMark let userLookup: Record = {} - let channelLookup: Record = {} + let channelLookup: Record = {} if (results.length > 0) { const userIds = new Set() - const channelIds = new Set() + const channelIdSet = new Set() for (const result of results) { userIds.add(result.creatorId) if (result.channelId) { - channelIds.add(result.channelId) + channelIdSet.add(result.channelId) } } const uniqueUserIds = Array.from(userIds) - const uniqueChannelIds = Array.from(channelIds) - const batchResponses = await client.batch( - ...uniqueUserIds.map((id) => - client.workspaceUsers.getUserById({ workspaceId, userId: id }, { batch: true }), + const uniqueChannelIds = Array.from(channelIdSet) + const [users, channels] = await Promise.all([ + Promise.all( + uniqueUserIds.map((id) => + client.workspaceUsers + .getUserById({ workspaceId, userId: id }) + .catch(() => null), + ), ), - ...uniqueChannelIds.map((id) => client.channels.getChannel(id, { batch: true })), - ) - - const userResponses = batchResponses.slice(0, uniqueUserIds.length) - const channelResponses = batchResponses.slice(uniqueUserIds.length) - - const users = userResponses.map((res) => res.data) - userLookup = users.reduce( - (acc, user) => { - acc[user.id] = user.name - return acc - }, - {} as Record, - ) - - const channels = channelResponses.map((res) => res.data) - channelLookup = channels.reduce( - (acc, channel) => { - acc[channel.id] = channel.name - return acc - }, - {} as Record, - ) + Promise.all( + uniqueChannelIds.map((id) => client.channels.getChannel(id).catch(() => null)), + ), + ]) + + userLookup = users.reduce>((acc, user) => { + if (user) acc[user.id] = user.fullName + return acc + }, {}) + + channelLookup = channels.reduce>((acc, channel) => { + if (channel) acc[channel.id] = channel.name + return acc + }, {}) } const lines: string[] = [`# Mentions in Workspace ${workspaceId}`, ''] @@ -179,7 +174,7 @@ const getMentions = { results: results.map((r) => { let url: string if (r.type === 'thread' && r.threadId !== undefined) { - url = getFullTwistURL({ + url = getFullCommsURL({ workspaceId, threadId: r.threadId, channelId: r.channelId, @@ -189,19 +184,19 @@ const getMentions = { r.threadId !== undefined && r.channelId !== undefined ) { - url = getFullTwistURL({ + url = getFullCommsURL({ workspaceId, threadId: r.threadId, channelId: r.channelId, commentId: r.id, }) } else if (r.type === 'conversation' && r.conversationId !== undefined) { - url = getFullTwistURL({ + url = getFullCommsURL({ workspaceId, conversationId: r.conversationId, }) } else if (r.type === 'message' && r.conversationId !== undefined) { - url = getFullTwistURL({ + url = getFullCommsURL({ workspaceId, conversationId: r.conversationId, messageId: r.id, @@ -226,6 +221,6 @@ const getMentions = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { getMentions, type GetMentionsStructured } diff --git a/src/tools/get-users.ts b/src/tools/get-users.ts index deca615..a374ac1 100644 --- a/src/tools/get-users.ts +++ b/src/tools/get-users.ts @@ -1,7 +1,7 @@ -import type { UserType } from '@doist/twist-sdk' +import type { UserType } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { GetUsersOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' @@ -25,7 +25,6 @@ type UserData = { shortName: string email?: string userType: UserType - bot: boolean removed: boolean timezone: string } @@ -52,16 +51,11 @@ const getUsers = { const users = !userIds || userIds.length === 0 ? await client.workspaceUsers.getWorkspaceUsers({ workspaceId }) - : await (async () => { - const userRequests = userIds.map((userId) => - client.workspaceUsers.getUserById( - { workspaceId, userId }, - { batch: true }, - ), - ) - const userResponses = await client.batch(...userRequests) - return userResponses.map((response) => response.data) - })() + : await Promise.all( + userIds.map((userId) => + client.workspaceUsers.getUserById({ workspaceId, userId }), + ), + ) const totalUsers = users.length @@ -70,7 +64,7 @@ const getUsers = { if (searchText) { const searchLower = searchText.toLowerCase() filteredUsers = users.filter((user) => { - const nameMatch = user.name.toLowerCase().includes(searchLower) + const nameMatch = user.fullName.toLowerCase().includes(searchLower) const emailMatch = user.email?.toLowerCase().includes(searchLower) || false return nameMatch || emailMatch }) @@ -89,7 +83,7 @@ const getUsers = { lines.push('No users found.') } else { for (const user of filteredUsers) { - lines.push(`## ${user.name}${user.bot ? ' 🤖' : ''}`) + lines.push(`## ${user.fullName}`) lines.push(`**ID:** ${user.id}`) if (user.email) { lines.push(`**Email:** ${user.email}`) @@ -108,11 +102,10 @@ const getUsers = { workspaceId, users: filteredUsers.map((user) => ({ id: user.id, - name: user.name, + name: user.fullName, shortName: user.shortName, ...(user.email && { email: user.email }), userType: user.userType, - bot: user.bot, removed: user.removed, timezone: user.timezone, })), @@ -125,6 +118,6 @@ const getUsers = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { getUsers, type GetUsersStructured } diff --git a/src/tools/get-workspaces.ts b/src/tools/get-workspaces.ts index 6962e57..8da9a18 100644 --- a/src/tools/get-workspaces.ts +++ b/src/tools/get-workspaces.ts @@ -1,9 +1,9 @@ -import type { TwistApi, WorkspacePlan } from '@doist/twist-sdk' +import type { CommsApi, WorkspacePlan } from '@doist/comms-sdk' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { GetWorkspacesOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' -import { getChannelUrl, getConversationUrl, getWorkspaceUrl } from '../utils/url-helpers.js' +import { getConversationUrl, getWorkspaceUrl } from '../utils/url-helpers.js' const ArgsSchema = {} @@ -14,10 +14,7 @@ type WorkspaceData = { creatorName?: string created: string url: string - defaultChannel?: number - defaultChannelName?: string - defaultChannelUrl?: string - defaultConversation?: number + defaultConversation?: string defaultConversationTitle?: string defaultConversationUrl?: string plan?: WorkspacePlan @@ -36,7 +33,7 @@ type GetWorkspacesStructured = Record & { } async function generateWorkspacesList( - client: TwistApi, + client: CommsApi, ): Promise<{ textContent: string; structuredContent: GetWorkspacesStructured }> { const workspaces = await client.workspaces.getWorkspaces() @@ -50,98 +47,57 @@ async function generateWorkspacesList( } } - // Collect all unique channel IDs, conversation IDs, and creator IDs - const channelIds = new Set() - const conversationIds = new Set() - const creatorIds = new Set() + // Collect default conversation IDs (paired with the workspace they belong to) + // and unique creator IDs (paired with workspace IDs so we can do per-workspace + // user lookups). + const defaultConversationPairs: Array<{ workspaceId: number; conversationId: string }> = [] + const creatorPairs: Array<{ workspaceId: number; creatorId: number }> = [] for (const workspace of workspaces) { - if (workspace.defaultChannel) { - channelIds.add(workspace.defaultChannel) - } if (workspace.defaultConversation) { - conversationIds.add(workspace.defaultConversation) - } - creatorIds.add(workspace.creator) - } - - // Fetch channels, conversations, and users in separate batch calls - // This makes the code clearer and easier to maintain - - // Batch 1: Fetch all channels - const channelLookup: Record = {} - if (channelIds.size > 0) { - const channelRequests = Array.from(channelIds).map((channelId) => - client.channels.getChannel(channelId, { batch: true }), - ) - const channelResponses = await client.batch(...channelRequests) - - const channelIdArray = Array.from(channelIds) - for (let i = 0; i < channelIdArray.length; i++) { - const channelId = channelIdArray[i] - if (channelId !== undefined) { - const channel = channelResponses[i]?.data - if (channel) { - channelLookup[channelId] = channel.name - } - } + defaultConversationPairs.push({ + workspaceId: workspace.id, + conversationId: workspace.defaultConversation, + }) } + creatorPairs.push({ workspaceId: workspace.id, creatorId: workspace.creator }) } - // Batch 2: Fetch all conversations - const conversationLookup: Record = {} - if (conversationIds.size > 0) { - const conversationRequests = Array.from(conversationIds).map((conversationId) => - client.conversations.getConversation(conversationId, { batch: true }), + // Fetch default conversations + const conversationLookup: Record = {} + if (defaultConversationPairs.length > 0) { + const conversations = await Promise.all( + defaultConversationPairs.map(({ conversationId }) => + client.conversations.getConversation(conversationId).catch(() => null), + ), ) - const conversationResponses = await client.batch(...conversationRequests) - - const conversationIdArray = Array.from(conversationIds) - for (let i = 0; i < conversationIdArray.length; i++) { - const conversationId = conversationIdArray[i] - if (conversationId !== undefined) { - const conversation = conversationResponses[i]?.data - if (conversation) { - // Conversations have a 'title' field which may be null, fallback to user IDs - const title = - conversation.title || - `Conversation with users: ${conversation.userIds.join(', ')}` - conversationLookup[conversationId] = title - } + for (let i = 0; i < defaultConversationPairs.length; i++) { + const pair = defaultConversationPairs[i] + const conversation = conversations[i] + if (pair && conversation) { + const title = + conversation.title || + `Conversation with users: ${conversation.userIds.join(', ')}` + conversationLookup[pair.conversationId] = title } } } - // Batch 3: Fetch all workspace users - // Note: We need to know the workspace ID for getUserById + // Fetch all workspace creators const creatorLookup: Record = {} - if (creatorIds.size > 0) { - const workspaceIdByCreatorId = new Map() - for (const workspace of workspaces) { - workspaceIdByCreatorId.set(workspace.creator, workspace.id) - } - - const userRequests = Array.from(creatorIds) - .map((creatorId) => { - const workspaceId = workspaceIdByCreatorId.get(creatorId) - if (!workspaceId) return null - return client.workspaceUsers.getUserById( - { workspaceId, userId: creatorId }, - { batch: true }, - ) - }) - .filter((req): req is Exclude => req !== null) - - const userResponses = await client.batch(...userRequests) - - const creatorIdArray = Array.from(creatorIds) - for (let i = 0; i < creatorIdArray.length; i++) { - const creatorId = creatorIdArray[i] - if (creatorId !== undefined) { - const user = userResponses[i]?.data - if (user) { - creatorLookup[creatorId] = user.name - } + if (creatorPairs.length > 0) { + const users = await Promise.all( + creatorPairs.map(({ workspaceId, creatorId }) => + client.workspaceUsers + .getUserById({ workspaceId, userId: creatorId }) + .catch(() => null), + ), + ) + for (let i = 0; i < creatorPairs.length; i++) { + const pair = creatorPairs[i] + const user = users[i] + if (pair && user) { + creatorLookup[pair.creatorId] = user.fullName } } } @@ -151,9 +107,6 @@ async function generateWorkspacesList( for (const workspace of workspaces) { const creatorName = creatorLookup[workspace.creator] - const defaultChannelName = workspace.defaultChannel - ? channelLookup[workspace.defaultChannel] - : undefined const defaultConversationTitle = workspace.defaultConversation ? conversationLookup[workspace.defaultConversation] : undefined @@ -165,13 +118,6 @@ async function generateWorkspacesList( ) lines.push(`**Created:** ${workspace.created.toISOString()}`) - if (workspace.defaultChannel) { - const channelUrl = getChannelUrl(workspace.id, workspace.defaultChannel) - lines.push( - `**Default Channel:** ${defaultChannelName ? `[${defaultChannelName}](${channelUrl}) (${workspace.defaultChannel})` : `[${workspace.defaultChannel}](${channelUrl})`}`, - ) - } - if (workspace.defaultConversation) { const conversationUrl = getConversationUrl(workspace.id, workspace.defaultConversation) lines.push( @@ -199,14 +145,6 @@ async function generateWorkspacesList( }), created: workspace.created.toISOString(), url: getWorkspaceUrl(workspace.id), - ...(workspace.defaultChannel && { defaultChannel: workspace.defaultChannel }), - ...(workspace.defaultChannel && - channelLookup[workspace.defaultChannel] && { - defaultChannelName: channelLookup[workspace.defaultChannel], - }), - ...(workspace.defaultChannel && { - defaultChannelUrl: getChannelUrl(workspace.id, workspace.defaultChannel), - }), ...(workspace.defaultConversation && { defaultConversation: workspace.defaultConversation, }), @@ -232,7 +170,7 @@ async function generateWorkspacesList( const getWorkspaces = { name: ToolNames.GET_WORKSPACES, description: - 'Get all workspaces that the user belongs to. Returns a list of workspaces with their IDs, names, creators, creation dates, and optional default channels, conversations, and plan information.', + 'Get all workspaces that the user belongs to. Returns a list of workspaces with their IDs, names, creators, creation dates, default conversation, and plan information.', parameters: ArgsSchema, outputSchema: GetWorkspacesOutputSchema.shape, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, @@ -244,6 +182,6 @@ const getWorkspaces = { structuredContent: result.structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { getWorkspaces, type GetWorkspacesStructured } diff --git a/src/tools/list-channels.ts b/src/tools/list-channels.ts index b55a198..f8e4e42 100644 --- a/src/tools/list-channels.ts +++ b/src/tools/list-channels.ts @@ -1,7 +1,8 @@ -import type { Channel, TwistApi } from '@doist/twist-sdk' +import type { Channel, CommsApi } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' +import { limitedAll } from '../utils/concurrency.js' import { ListChannelsOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' import { getChannelUrl } from '../utils/url-helpers.js' @@ -17,7 +18,7 @@ const ArgsSchema = { } type ChannelData = { - id: number + id: string name: string description?: string public: boolean @@ -37,18 +38,18 @@ type ListChannelsStructured = Record & { } async function generateChannelsList( - client: TwistApi, + client: CommsApi, workspaceId: number, includeArchived: boolean, ): Promise<{ textContent: string; structuredContent: ListChannelsStructured }> { // By default only fetch active channels; optionally include archived ones too let channels: Channel[] if (includeArchived) { - const [activeResponse, archivedResponse] = await client.batch( - client.channels.getChannels({ workspaceId }, { batch: true }), - client.channels.getChannels({ workspaceId, archived: true }, { batch: true }), - ) - channels = [...activeResponse.data, ...archivedResponse.data] + const [active, archived] = await Promise.all([ + client.channels.getChannels({ workspaceId }), + client.channels.getChannels({ workspaceId, archived: true }), + ]) + channels = [...active, ...archived] } else { channels = await client.channels.getChannels({ workspaceId }) } @@ -65,27 +66,28 @@ async function generateChannelsList( } } - // Collect unique creator IDs and batch-fetch their names + // Collect unique creator IDs and fetch their names const creatorIds = new Set() for (const channel of channels) { creatorIds.add(channel.creator) } + // Look up creator names in parallel, tolerating individual failures so a + // single deleted/inaccessible creator doesn't fail the whole list — the + // fallback path (creator ID without a name) is exercised by the text output. + // Bounded concurrency keeps the socket pool / rate limiter happy on big + // workspaces; today's traffic is small but the ceiling matters when it isn't. const creatorLookup: Record = {} if (creatorIds.size > 0) { - const userRequests = Array.from(creatorIds).map((userId) => - client.workspaceUsers.getUserById({ workspaceId, userId }, { batch: true }), - ) - const userResponses = await client.batch(...userRequests) - const creatorIdArray = Array.from(creatorIds) + const users = await limitedAll(creatorIdArray, (userId) => + client.workspaceUsers.getUserById({ workspaceId, userId }).catch(() => null), + ) for (let i = 0; i < creatorIdArray.length; i++) { const creatorId = creatorIdArray[i] - if (creatorId !== undefined) { - const user = userResponses[i]?.data - if (user) { - creatorLookup[creatorId] = user.name - } + const user = users[i] + if (creatorId !== undefined && user) { + creatorLookup[creatorId] = user.fullName } } } @@ -157,6 +159,6 @@ const listChannels = { structuredContent: result.structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { listChannels, type ListChannelsStructured } diff --git a/src/tools/load-conversation.ts b/src/tools/load-conversation.ts index 7866518..22e797c 100644 --- a/src/tools/load-conversation.ts +++ b/src/tools/load-conversation.ts @@ -1,12 +1,12 @@ -import { getFullTwistURL, type WorkspaceUser } from '@doist/twist-sdk' +import { getFullCommsURL, type WorkspaceUser } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { LoadConversationOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' const ArgsSchema = { - conversationId: z.number().describe('The conversation ID to load.'), + conversationId: z.string().describe('The conversation ID to load.'), newerThanDate: z .string() .optional() @@ -33,7 +33,7 @@ const ArgsSchema = { type LoadConversationStructured = { type: 'conversation_data' conversation: { - id: number + id: string workspaceId: number userIds: number[] archived: boolean @@ -42,11 +42,11 @@ type LoadConversationStructured = { conversationUrl: string } messages: Array<{ - id: number + id: string content: string creatorId: number creatorName?: string - conversationId: number + conversationId: string posted: string messageUrl: string }> @@ -63,39 +63,30 @@ const loadConversation = { async execute(args, client) { const { conversationId, newerThanDate, olderThanDate, limit, includeParticipants } = args - // Fetch conversation metadata and messages in parallel using batch - const [conversationResponse, messagesResponse] = await client.batch( - client.conversations.getConversation(conversationId, { batch: true }), - client.conversationMessages.getMessages( - { - conversationId, - newerThan: newerThanDate ? new Date(newerThanDate) : undefined, - olderThan: olderThanDate ? new Date(olderThanDate) : undefined, - limit, - }, - { batch: true }, - ), - ) - - const conversation = conversationResponse.data - const messages = messagesResponse.data + // Fetch conversation metadata and messages in parallel + const [conversation, messages] = await Promise.all([ + client.conversations.getConversation(conversationId), + client.conversationMessages.getMessages({ + conversationId, + newerThan: newerThanDate ? new Date(newerThanDate) : undefined, + olderThan: olderThanDate ? new Date(olderThanDate) : undefined, + limit, + }), + ]) const { userIds } = conversation - const userRequests = userIds.map((id) => - client.workspaceUsers.getUserById( - { workspaceId: conversation.workspaceId, userId: id }, - { batch: true }, + const users = await Promise.all( + userIds.map((id) => + client.workspaceUsers.getUserById({ + workspaceId: conversation.workspaceId, + userId: id, + }), ), ) - const userResponses = await client.batch(...userRequests) - const users = userResponses.map((res) => res.data) - const userInfo = users.reduce( - (acc, user) => { - acc[user.id] = user - return acc - }, - {} as Record, - ) + const userInfo = users.reduce>((acc, user) => { + acc[user.id] = user + return acc + }, {}) // Build text content const lines: string[] = [ @@ -112,7 +103,7 @@ const loadConversation = { if (includeParticipants) { lines.push('## Participants') lines.push('') - lines.push(conversation.userIds.map((id) => userInfo[id]?.name).join(', ')) + lines.push(conversation.userIds.map((id) => userInfo[id]?.fullName).join(', ')) lines.push('') } @@ -123,7 +114,7 @@ const loadConversation = { const messageDate = message.posted.toISOString() lines.push(`### Message ${message.id}`) lines.push( - `**Creator:** ${userInfo[message.creator]?.name} | **Posted:** ${messageDate}`, + `**Creator:** ${userInfo[message.creator]?.fullName} | **Posted:** ${messageDate}`, ) lines.push('') lines.push(message.content) @@ -141,7 +132,7 @@ const loadConversation = { lastActive: conversation.lastActive.toISOString(), conversationUrl: conversation.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: conversation.workspaceId, conversationId: conversation.id, }), @@ -150,12 +141,12 @@ const loadConversation = { id: m.id, content: m.content, creatorId: m.creator, - creatorName: userInfo[m.creator]?.name, + creatorName: userInfo[m.creator]?.fullName, conversationId: m.conversationId, posted: m.posted.toISOString(), messageUrl: m.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: m.workspaceId, conversationId: m.conversationId, messageId: m.id, @@ -169,6 +160,6 @@ const loadConversation = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { loadConversation, type LoadConversationStructured } diff --git a/src/tools/load-thread.ts b/src/tools/load-thread.ts index 953fb15..af16e3f 100644 --- a/src/tools/load-thread.ts +++ b/src/tools/load-thread.ts @@ -1,12 +1,12 @@ -import { getFullTwistURL } from '@doist/twist-sdk' +import { getFullCommsURL } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { LoadThreadOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' const ArgsSchema = { - threadId: z.number().describe('The thread ID to load.'), + threadId: z.string().describe('The thread ID to load.'), newerThanDate: z .string() .optional() @@ -33,10 +33,10 @@ const ArgsSchema = { type LoadThreadStructured = { type: 'thread_data' thread: { - id: number + id: string title: string content: string - channelId: number + channelId: string channelName?: string workspaceId: number creator: number @@ -50,11 +50,11 @@ type LoadThreadStructured = { threadUrl: string } comments: Array<{ - id: number + id: string content: string creator: number creatorName?: string - threadId: number + threadId: string posted: string commentUrl: string }> @@ -69,23 +69,18 @@ const loadThread = { outputSchema: LoadThreadOutputSchema.shape, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, async execute(args, client) { - const { threadId, newerThanDate, limit, includeParticipants } = args + const { threadId, newerThanDate, olderThanDate, limit, includeParticipants } = args - // Fetch thread metadata and comments in parallel using batch - const [threadResponse, commentsResponse] = await client.batch( - client.threads.getThread(threadId, { batch: true }), - client.comments.getComments( - { - threadId, - from: newerThanDate ? new Date(newerThanDate) : undefined, - limit, - }, - { batch: true }, - ), - ) - - const thread = threadResponse.data - const comments = commentsResponse.data + // Fetch thread metadata and comments in parallel + const [thread, comments] = await Promise.all([ + client.threads.getThread(threadId), + client.comments.getComments({ + threadId, + newerThan: newerThanDate ? new Date(newerThanDate) : undefined, + olderThan: olderThanDate ? new Date(olderThanDate) : undefined, + limit, + }), + ]) // Collect all unique user IDs const userIds = new Set([thread.creator]) @@ -98,27 +93,19 @@ const loadThread = { } } - // Fetch channel and all user info in a single batch call + // Fetch channel and all user info in parallel const uniqueUserIds = Array.from(userIds) - const [channelResponse, ...userResponses] = await client.batch( - client.channels.getChannel(thread.channelId, { batch: true }), + const [channel, ...users] = await Promise.all([ + client.channels.getChannel(thread.channelId), ...uniqueUserIds.map((id) => - client.workspaceUsers.getUserById( - { workspaceId: thread.workspaceId, userId: id }, - { batch: true }, - ), + client.workspaceUsers.getUserById({ workspaceId: thread.workspaceId, userId: id }), ), - ) + ]) - const channel = channelResponse.data - const users = userResponses.map((res) => res.data) - const userLookup = users.reduce( - (acc, user) => { - acc[user.id] = user.name - return acc - }, - {} as Record, - ) + const userLookup = users.reduce>((acc, user) => { + acc[user.id] = user.fullName + return acc + }, {}) // Build text content const creatorName = userLookup[thread.creator] @@ -187,7 +174,7 @@ const loadThread = { : undefined, threadUrl: thread.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: thread.workspaceId, channelId: thread.channelId, threadId: thread.id, @@ -202,7 +189,7 @@ const loadThread = { posted: c.posted.toISOString(), commentUrl: c.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: c.workspaceId, channelId: c.channelId, threadId: c.threadId, @@ -217,6 +204,6 @@ const loadThread = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { loadThread, type LoadThreadStructured } diff --git a/src/tools/mark-done.ts b/src/tools/mark-done.ts index 6366090..709114c 100644 --- a/src/tools/mark-done.ts +++ b/src/tools/mark-done.ts @@ -1,6 +1,7 @@ import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' +import { limitedAll } from '../utils/concurrency.js' import { MarkDoneOutputSchema } from '../utils/output-schemas.js' import { type MarkDoneType, MarkDoneTypeSchema } from '../utils/target-types.js' import { ToolNames } from '../utils/tool-names.js' @@ -8,9 +9,9 @@ import { ToolNames } from '../utils/tool-names.js' const ArgsSchema = { type: MarkDoneTypeSchema.describe('The type of items to mark as done: thread or conversation.'), - // Individual IDs + // Individual IDs (thread/conversation IDs are strings) ids: z - .array(z.number()) + .array(z.string()) .optional() .describe( 'Specific thread or conversation IDs to mark as done. Use this OR bulk selectors.', @@ -22,7 +23,7 @@ const ArgsSchema = { .optional() .describe('Mark all threads in this workspace as done (threads only).'), channelId: z - .number() + .string() .optional() .describe('Mark all threads in this channel as done (threads only).'), @@ -44,8 +45,8 @@ type MarkDoneStructured = { type: 'mark_done_result' itemType: MarkDoneType mode: 'individual' | 'bulk' - completed: number[] - failed: Array<{ item: number; error: string }> + completed: string[] + failed: Array<{ item: string; error: string }> totalRequested: number successCount: number failureCount: number @@ -56,7 +57,7 @@ type MarkDoneStructured = { } selectors?: { workspaceId?: number - channelId?: number + channelId?: string } } @@ -78,8 +79,8 @@ const markDone = { clearUnread = false, } = args - const completed: number[] = [] - const failed: Array<{ item: number; error: string }> = [] + const completed: string[] = [] + const failed: Array<{ item: string; error: string }> = [] let mode: 'individual' | 'bulk' = 'individual' // Validate arguments @@ -93,33 +94,54 @@ const markDone = { ) } + // Reject channel-only bulk archive up front: the SDK's `inbox.archiveAll` + // requires a workspaceId, so without one we'd silently drop the archive + // step while reporting success — exactly the "lie about state" failure + // mode this tool must avoid. + if (type === 'thread' && archive && channelId && !workspaceId) { + throw new Error( + 'Archiving by channelId requires workspaceId. Pass both, or pass individual `ids` instead.', + ) + } + try { // Bulk operations (threads only) if (type === 'thread' && (workspaceId || channelId)) { mode = 'bulk' - // Clear unread takes precedence + // Clear unread takes precedence; it's strictly workspace-scoped + // (the SDK only exposes a workspace-level signature), so we + // require workspaceId for it. if (clearUnread && workspaceId) { await client.threads.clearUnread(workspaceId) } else { - // Mark all read + // Mark all read — pass both selectors when both are present + // so the call stays scoped to the channel inside the + // workspace rather than nuking the whole workspace. if (markRead) { - if (workspaceId) { + if (workspaceId && channelId) { + await client.threads.markAllRead({ workspaceId, channelId }) + } else if (workspaceId) { await client.threads.markAllRead({ workspaceId }) } else if (channelId) { await client.threads.markAllRead({ channelId }) } } - // Archive all (inbox operations) - if (archive) { - if (workspaceId) { - await client.inbox.archiveAll({ workspaceId }) - } else if (channelId) { + // Archive all (inbox operations). `archiveAll` requires a + // workspaceId; with a channelId also present, pass it via + // `channelIds` so the archive stays scoped to that channel. + // The channel-only case is rejected up front in the + // validation above so this branch is reachable only with + // a workspaceId. + if (archive && workspaceId) { + if (channelId) { await client.inbox.archiveAll({ - workspaceId: 0, + workspaceId, channelIds: [channelId], }) + } else { + await client.inbox.archiveAll({ workspaceId }) } } } @@ -127,72 +149,43 @@ const markDone = { // We don't get individual IDs back from bulk operations // Just indicate success } else if (ids && ids.length > 0) { - // Individual operations - batch them together + // Individual operations - run them in parallel mode = 'individual' - // Build array of batch operations for each ID - const operations = [] - for (const id of ids) { - if (type === 'thread') { - // Mark thread as read - if (markRead) { - operations.push( - client.threads.markRead({ id, objIndex: 0 }, { batch: true }), - ) - } - // Archive thread in inbox - if (archive) { - operations.push(client.inbox.archiveThread(id, { batch: true })) - } - } else { - // Mark conversation as read - if (markRead) { - operations.push(client.conversations.markRead({ id }, { batch: true })) - } - // Archive conversation - if (archive) { - operations.push( - client.conversations.archiveConversation(id, { batch: true }), - ) + // Try all operations with bounded concurrency; if any + // individual ID fails, record it but keep going. The `ids` + // list is user-supplied so it can be large — `limitedAll` + // keeps the burst inside the socket pool / rate limiter. + const results = await limitedAll(ids, async (id) => { + try { + if (type === 'thread') { + if (markRead) { + await client.threads.markRead({ id, objIndex: 0 }) + } + if (archive) { + await client.inbox.archiveThread(id) + } + } else { + if (markRead) { + await client.conversations.markRead({ id }) + } + if (archive) { + await client.conversations.archiveConversation(id) + } } + return { id, ok: true as const } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + return { id, ok: false as const, errorMessage } } - } + }) - // Execute all operations in batch - try { - await client.batch(...operations) - // All operations succeeded - completed.push(...ids) - } catch (_error) { - // If batch fails, we need to fall back to individual operations to track which ones failed - for (const id of ids) { - try { - if (type === 'thread') { - if (markRead) { - await client.threads.markRead({ id, objIndex: 0 }) - } - if (archive) { - await client.inbox.archiveThread(id) - } - } else { - if (markRead) { - await client.conversations.markRead({ id }) - } - if (archive) { - await client.conversations.archiveConversation(id) - } - } - completed.push(id) - } catch (individualError) { - const errorMessage = - individualError instanceof Error - ? individualError.message - : 'Unknown error' - failed.push({ - item: id, - error: errorMessage, - }) - } + for (const r of results) { + if (r.ok) { + completed.push(r.id) + } else { + failed.push({ item: r.id, error: r.errorMessage }) } } } @@ -224,7 +217,7 @@ const markDone = { lines.push(`**Archive:** ${archive ? 'Yes' : 'No'}`) } lines.push('') - lines.push('✅ Bulk operation completed successfully') + lines.push('Bulk operation completed successfully') } else { lines.push(`**Total Requested:** ${ids?.length ?? 0}`) lines.push(`**Successful:** ${completed.length}`) @@ -291,6 +284,6 @@ const markDone = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { markDone, type MarkDoneStructured } diff --git a/src/tools/react.ts b/src/tools/react.ts index 937069b..8c62297 100644 --- a/src/tools/react.ts +++ b/src/tools/react.ts @@ -1,7 +1,7 @@ -import { getFullTwistURL } from '@doist/twist-sdk' +import { getFullCommsURL } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { ReactOutputSchema } from '../utils/output-schemas.js' import { type ReactionTargetType, ReactionTargetTypeSchema } from '../utils/target-types.js' import { ToolNames } from '../utils/tool-names.js' @@ -10,7 +10,7 @@ const ArgsSchema = { targetType: ReactionTargetTypeSchema.describe( 'The type of object to react to: thread, comment, or message.', ), - targetId: z.number().describe('The ID of the thread, comment, or message to react to.'), + targetId: z.string().describe('The ID of the thread, comment, or message to react to.'), emoji: z.string().min(1).describe('The emoji to react with (e.g., "👍", "❤️", "🎉").'), operation: z .enum(['add', 'remove']) @@ -23,7 +23,7 @@ type ReactStructured = { success: boolean operation: 'add' | 'remove' targetType: ReactionTargetType - targetId: number + targetId: string emoji: string targetUrl: string } @@ -45,7 +45,7 @@ const react = { const thread = await client.threads.getThread(targetId) targetUrl = thread.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: thread.workspaceId, channelId: thread.channelId, threadId: thread.id, @@ -54,7 +54,7 @@ const react = { const comment = await client.comments.getComment(targetId) targetUrl = comment.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: comment.workspaceId, channelId: comment.channelId, threadId: comment.threadId, @@ -65,7 +65,7 @@ const react = { const message = await client.conversationMessages.getMessage(targetId) targetUrl = message.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: message.workspaceId, conversationId: message.conversationId, messageId: message.id, @@ -74,9 +74,9 @@ const react = { // Map targetType to the appropriate API parameter const apiParams: { - threadId?: number - commentId?: number - messageId?: number + threadId?: string + commentId?: string + messageId?: string reaction: string } = { reaction: emoji } @@ -90,9 +90,9 @@ const react = { // Perform the reaction operation if (operation === 'add') { - await client.reactions.add(apiParams) + await client.reactions.add({ ...apiParams, reaction: emoji }) } else { - await client.reactions.remove(apiParams) + await client.reactions.remove({ ...apiParams, reaction: emoji }) } const lines: string[] = [ @@ -118,6 +118,6 @@ const react = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { react, type ReactStructured } diff --git a/src/tools/reply.ts b/src/tools/reply.ts index 7fd5376..f0a25a8 100644 --- a/src/tools/reply.ts +++ b/src/tools/reply.ts @@ -1,7 +1,7 @@ -import { getFullTwistURL, NOTIFY_AUDIENCES, type NotifyAudience } from '@doist/twist-sdk' +import { getFullCommsURL, NOTIFY_AUDIENCES, type NotifyAudience } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { type ReplyOutput, ReplyOutputSchema } from '../utils/output-schemas.js' import { ReplyTargetTypeSchema } from '../utils/target-types.js' import { ToolNames } from '../utils/tool-names.js' @@ -10,7 +10,7 @@ const ArgsSchema = { targetType: ReplyTargetTypeSchema.describe( 'The type of object to reply to: thread (posts a comment) or conversation (posts a message).', ), - targetId: z.number().describe('The ID of the thread or conversation to reply to.'), + targetId: z.string().describe('The ID of the thread or conversation to reply to.'), content: z.string().min(1).describe('The content of the reply.'), recipients: z .array(z.number()) @@ -19,7 +19,7 @@ const ArgsSchema = { 'Optional array of user IDs to notify (only for thread replies). If omitted with no groups and no notifyAudience, thread replies default to notifying everyone who has interacted with the thread.', ), groups: z - .array(z.number()) + .array(z.string()) .optional() .describe( 'Optional array of group IDs to notify (only for thread replies). Use get-groups to discover group IDs before passing them here.', @@ -57,7 +57,7 @@ const reply = { (recipients === undefined && !groupsToNotify ? 'thread' : undefined)) : undefined - let replyId: number + let replyId: string let created: Date let replyUrl: string @@ -72,7 +72,7 @@ const reply = { replyId = comment.id replyUrl = comment.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: comment.workspaceId, channelId: comment.channelId, threadId: comment.threadId, @@ -92,7 +92,7 @@ const reply = { replyId = message.id replyUrl = message.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: message.workspaceId, conversationId: message.conversationId, messageId: message.id, @@ -136,7 +136,7 @@ const reply = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool type ReplyStructured = ReplyOutput diff --git a/src/tools/search-content.ts b/src/tools/search-content.ts index ae7d484..320dbd3 100644 --- a/src/tools/search-content.ts +++ b/src/tools/search-content.ts @@ -1,14 +1,14 @@ -import { type SearchResultType, getFullTwistURL } from '@doist/twist-sdk' +import { type SearchResultType, getFullCommsURL } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { SearchContentOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' const ArgsSchema = { query: z.string().min(1).describe('The search query string.'), workspaceId: z.number().describe('The workspace ID to search in.'), - channelIds: z.array(z.number()).optional().describe('Filter by channel IDs.'), + channelIds: z.array(z.string()).optional().describe('Filter by channel IDs.'), authorIds: z.array(z.number()).optional().describe('Filter by author user IDs.'), mentionSelf: z.boolean().optional().describe('Filter by mentions of current user.'), dateFrom: z.string().optional().describe('Start date for filtering (YYYY-MM-DD).'), @@ -35,9 +35,9 @@ type SearchContentStructured = { creatorId: number creatorName?: string created: string - threadId?: number - conversationId?: number - channelId?: number + threadId?: string + conversationId?: string + channelId?: string channelName?: string workspaceId: number url: string @@ -97,52 +97,45 @@ const searchContent = { // Initialize lookup maps let userLookup: Record = {} - let channelLookup: Record = {} + let channelLookup: Record = {} // Only fetch user and channel info if there are results if (results.length > 0) { // Collect unique user IDs and channel IDs const userIds = new Set() - const channelIds = new Set() + const channelIdSet = new Set() for (const result of results) { userIds.add(result.creatorId) if (result.channelId) { - channelIds.add(result.channelId) + channelIdSet.add(result.channelId) } } - // Fetch all users and channels in a single batch call + // Fetch all users and channels in parallel const uniqueUserIds = Array.from(userIds) - const uniqueChannelIds = Array.from(channelIds) - const batchResponses = await client.batch( - ...uniqueUserIds.map((id) => - client.workspaceUsers.getUserById({ workspaceId, userId: id }, { batch: true }), + const uniqueChannelIds = Array.from(channelIdSet) + const [users, channels] = await Promise.all([ + Promise.all( + uniqueUserIds.map((id) => + client.workspaceUsers + .getUserById({ workspaceId, userId: id }) + .catch(() => null), + ), ), - ...uniqueChannelIds.map((id) => client.channels.getChannel(id, { batch: true })), - ) - - // Split responses into users and channels - const userResponses = batchResponses.slice(0, uniqueUserIds.length) - const channelResponses = batchResponses.slice(uniqueUserIds.length) + Promise.all( + uniqueChannelIds.map((id) => client.channels.getChannel(id).catch(() => null)), + ), + ]) - // Build lookup maps - const users = userResponses.map((res) => res.data) - userLookup = users.reduce( - (acc, user) => { - acc[user.id] = user.name - return acc - }, - {} as Record, - ) + userLookup = users.reduce>((acc, user) => { + if (user) acc[user.id] = user.fullName + return acc + }, {}) - const channels = channelResponses.map((res) => res.data) - channelLookup = channels.reduce( - (acc, channel) => { - acc[channel.id] = channel.name - return acc - }, - {} as Record, - ) + channelLookup = channels.reduce>((acc, channel) => { + if (channel) acc[channel.id] = channel.name + return acc + }, {}) } // Build text content @@ -204,7 +197,7 @@ const searchContent = { results: results.map((r) => { let url: string if (r.type === 'thread' && r.threadId !== undefined) { - url = getFullTwistURL({ + url = getFullCommsURL({ workspaceId, threadId: r.threadId, channelId: r.channelId, @@ -214,19 +207,19 @@ const searchContent = { r.threadId !== undefined && r.channelId !== undefined ) { - url = getFullTwistURL({ + url = getFullCommsURL({ workspaceId, threadId: r.threadId, channelId: r.channelId, commentId: r.id, }) } else if (r.type === 'conversation' && r.conversationId !== undefined) { - url = getFullTwistURL({ + url = getFullCommsURL({ workspaceId, conversationId: r.conversationId, }) } else if (r.type === 'message' && r.conversationId !== undefined) { - url = getFullTwistURL({ + url = getFullCommsURL({ workspaceId, conversationId: r.conversationId, messageId: r.id, @@ -252,6 +245,6 @@ const searchContent = { structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { searchContent, type SearchContentStructured } diff --git a/src/tools/update-object.ts b/src/tools/update-object.ts index 33b673a..b8c7248 100644 --- a/src/tools/update-object.ts +++ b/src/tools/update-object.ts @@ -1,7 +1,7 @@ -import { getFullTwistURL, type TwistApi } from '@doist/twist-sdk' +import { getFullCommsURL, type CommsApi } from '@doist/comms-sdk' import { z } from 'zod' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { type UpdateCommentOutput, type UpdateMessageOutput, @@ -17,7 +17,7 @@ const ArgsSchema = { 'The type of object to update: thread, comment, or message.', ), targetId: z - .number() + .string() .describe('The ID of the thread, comment, or conversation message to update.'), content: z .string() @@ -36,7 +36,7 @@ const ArgsSchema = { type Args = z.infer> type Branch = { textContent: string; structuredContent: UpdateObjectStructured } -async function updateThreadBranch(args: Args, client: TwistApi): Promise { +async function updateThreadBranch(args: Args, client: CommsApi): Promise { const { targetId, title, content } = args if (title === undefined && content === undefined) { @@ -47,7 +47,7 @@ async function updateThreadBranch(args: Args, client: TwistApi): Promise const threadUrl = thread.url ?? - getFullTwistURL({ + getFullCommsURL({ workspaceId: thread.workspaceId, channelId: thread.channelId, threadId: thread.id, @@ -84,7 +84,7 @@ async function updateThreadBranch(args: Args, client: TwistApi): Promise return { textContent: lines.join('\n'), structuredContent } } -async function updateCommentBranch(args: Args, client: TwistApi): Promise { +async function updateCommentBranch(args: Args, client: CommsApi): Promise { const { targetId, content } = args if (content === undefined) { throw new Error('`content` is required when targetType is "comment".') @@ -94,7 +94,7 @@ async function updateCommentBranch(args: Args, client: TwistApi): Promise { +async function updateMessageBranch(args: Args, client: CommsApi): Promise { const { targetId, content } = args if (content === undefined) { throw new Error('`content` is required when targetType is "message".') @@ -142,7 +142,7 @@ async function updateMessageBranch(args: Args, client: TwistApi): Promise +} satisfies CommsTool export { updateObject } diff --git a/src/tools/user-info.ts b/src/tools/user-info.ts index 45c1e7c..79dd38d 100644 --- a/src/tools/user-info.ts +++ b/src/tools/user-info.ts @@ -1,6 +1,6 @@ -import type { TwistApi } from '@doist/twist-sdk' +import type { CommsApi } from '@doist/comms-sdk' +import type { CommsTool } from '../comms-tool.js' import { getToolOutput } from '../mcp-helpers.js' -import type { TwistTool } from '../twist-tool.js' import { UserInfoOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' @@ -10,14 +10,14 @@ type UserInfoStructured = Record & { type: 'user_info' userId: number name: string + shortName: string email: string timezone: string - bot: boolean - defaultWorkspace: number | null + lang: string } async function generateUserInfo( - client: TwistApi, + client: CommsApi, ): Promise<{ textContent: string; structuredContent: UserInfoStructured }> { const user = await client.users.getSessionUser() @@ -25,34 +25,23 @@ async function generateUserInfo( '# User Information', '', `**User ID:** ${user.id}`, - `**Name:** ${user.name}`, + `**Name:** ${user.fullName}`, + `**Short Name:** ${user.shortName}`, `**Email:** ${user.email}`, `**Timezone:** ${user.timezone}`, - `**Bot:** ${user.bot ? 'Yes' : 'No'}`, `**Language:** ${user.lang}`, ] - if (user.defaultWorkspace) { - lines.push(`**Default Workspace:** ${user.defaultWorkspace}`) - } - - if (user.awayMode) { - lines.push('', '## Away Mode') - lines.push(`**Type:** ${user.awayMode.type}`) - lines.push(`**From:** ${user.awayMode.dateFrom}`) - lines.push(`**To:** ${user.awayMode.dateTo}`) - } - const textContent = lines.join('\n') const structuredContent: UserInfoStructured = { type: 'user_info', userId: user.id, - name: user.name, + name: user.fullName, + shortName: user.shortName, email: user.email, timezone: user.timezone, - bot: user.bot, - defaultWorkspace: user.defaultWorkspace ?? null, + lang: user.lang, } return { textContent, structuredContent } @@ -61,7 +50,7 @@ async function generateUserInfo( const userInfo = { name: ToolNames.USER_INFO, description: - 'Get comprehensive user information including user ID, name, email, timezone, bot status, default workspace, and away mode status.', + 'Get information about the authenticated Comms user: user ID, full name, short name, email, timezone, and language.', parameters: ArgsSchema, outputSchema: UserInfoOutputSchema.shape, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, @@ -73,6 +62,6 @@ const userInfo = { structuredContent: result.structuredContent, }) }, -} satisfies TwistTool +} satisfies CommsTool export { userInfo, type UserInfoStructured } diff --git a/src/utils/concurrency.ts b/src/utils/concurrency.ts new file mode 100644 index 0000000..d781c2a --- /dev/null +++ b/src/utils/concurrency.ts @@ -0,0 +1,30 @@ +import pLimit from 'p-limit' + +/** + * Concurrency budget for fan-out call sites that hydrate auxiliary data + * (channel metadata, creator names, individual mark-done operations). + * + * Today these scale with workspace size and are invisible; the day someone + * runs against a 200-thread workspace they otherwise blow through the + * undici socket pool / API rate limiter. A small ceiling caps the burst + * without slowing the common small case. + */ +export const FANOUT_CONCURRENCY = 8 + +/** + * `Promise.all` with a bounded concurrency budget. Use at every fan-out + * site so the choice stays consistent and a single tweak (limit, retry, + * tracing) lands in one place instead of three. + * + * Each item runs `worker(item)` through a fresh `p-limit` instance — the + * limit is per-call, not shared across sites, so unrelated tools can't + * starve each other. + */ +export async function limitedAll( + items: readonly T[], + worker: (item: T) => Promise, + concurrency: number = FANOUT_CONCURRENCY, +): Promise { + const limit = pLimit(concurrency) + return Promise.all(items.map((item) => limit(() => worker(item)))) +} diff --git a/src/utils/output-schemas.ts b/src/utils/output-schemas.ts index 804ffe4..f31cdb7 100644 --- a/src/utils/output-schemas.ts +++ b/src/utils/output-schemas.ts @@ -13,7 +13,7 @@ import { UserSchema, WorkspaceSchema, WorkspaceUserSchema, -} from '@doist/twist-sdk' +} from '@doist/comms-sdk' import { z } from 'zod' // Re-export SDK schemas for direct use @@ -32,7 +32,12 @@ export { WorkspaceUserSchema, } -// Custom schemas for tool-specific structured outputs +// Custom schemas for tool-specific structured outputs. +// +// ID typing in the Comms API: +// - channel/thread/comment/conversation/message/group IDs are opaque +// base58-encoded UUIDv7 strings. +// - workspaceId and userId are numeric. /** * Schema for load-thread tool output @@ -40,10 +45,10 @@ export { export const LoadThreadOutputSchema = z.object({ type: z.literal('thread_data'), thread: z.object({ - id: z.number(), + id: z.string(), title: z.string(), content: z.string(), - channelId: z.number(), + channelId: z.string(), channelName: z.string().optional(), workspaceId: z.number(), creator: z.number(), @@ -58,11 +63,11 @@ export const LoadThreadOutputSchema = z.object({ }), comments: z.array( z.object({ - id: z.number(), + id: z.string(), content: z.string(), creator: z.number(), creatorName: z.string().optional(), - threadId: z.number(), + threadId: z.string(), posted: z.string(), commentUrl: z.string(), }), @@ -76,7 +81,7 @@ export const LoadThreadOutputSchema = z.object({ export const LoadConversationOutputSchema = z.object({ type: z.literal('conversation_data'), conversation: z.object({ - id: z.number(), + id: z.string(), workspaceId: z.number(), userIds: z.array(z.number()), archived: z.boolean(), @@ -86,11 +91,11 @@ export const LoadConversationOutputSchema = z.object({ }), messages: z.array( z.object({ - id: z.number(), + id: z.string(), content: z.string(), creatorId: z.number(), creatorName: z.string().optional(), - conversationId: z.number(), + conversationId: z.string(), posted: z.string(), messageUrl: z.string(), }), @@ -106,9 +111,9 @@ export const FetchInboxOutputSchema = z.object({ workspaceId: z.number(), threads: z.array( z.object({ - id: z.number(), + id: z.string(), title: z.string(), - channelId: z.number(), + channelId: z.string(), channelName: z.string().optional(), creator: z.number(), isUnread: z.boolean(), @@ -119,7 +124,7 @@ export const FetchInboxOutputSchema = z.object({ ), conversations: z.array( z.object({ - id: z.number(), + id: z.string(), title: z.string(), userIds: z.array(z.number()), participantNames: z.array(z.string()), @@ -149,9 +154,9 @@ export const SearchContentOutputSchema = z.object({ creatorId: z.number(), creatorName: z.string().optional(), created: z.string(), - threadId: z.number().optional(), - conversationId: z.number().optional(), - channelId: z.number().optional(), + threadId: z.string().optional(), + conversationId: z.string().optional(), + channelId: z.string().optional(), channelName: z.string().optional(), workspaceId: z.number(), url: z.string(), @@ -176,9 +181,9 @@ export const GetMentionsOutputSchema = z.object({ creatorId: z.number(), creatorName: z.string().optional(), created: z.string(), - threadId: z.number().optional(), - conversationId: z.number().optional(), - channelId: z.number().optional(), + threadId: z.string().optional(), + conversationId: z.string().optional(), + channelId: z.string().optional(), channelName: z.string().optional(), workspaceId: z.number(), url: z.string(), @@ -202,13 +207,10 @@ export const GetWorkspacesOutputSchema = z.object({ creatorName: z.string().optional(), created: z.string(), url: z.url(), - defaultChannel: z.number().optional(), - defaultChannelName: z.string().optional(), - defaultChannelUrl: z.url().optional(), - defaultConversation: z.number().optional(), + defaultConversation: z.string().optional(), defaultConversationTitle: z.string().optional(), defaultConversationUrl: z.url().optional(), - plan: z.string().optional(), // WorkspacePlan is a string union + plan: z.string().optional(), avatarId: z.string().optional(), avatarUrls: z .object({ @@ -234,8 +236,7 @@ export const GetUsersOutputSchema = z.object({ name: z.string(), shortName: z.string(), email: z.string().optional(), - userType: z.string(), // UserType is a string type - bot: z.boolean(), + userType: z.string(), removed: z.boolean(), timezone: z.string(), }), @@ -252,7 +253,7 @@ export const GetGroupsOutputSchema = z.object({ workspaceId: z.number(), groups: z.array( z.object({ - id: z.number(), + id: z.string(), name: z.string(), workspaceId: z.number(), memberCount: z.number(), @@ -262,36 +263,17 @@ export const GetGroupsOutputSchema = z.object({ filteredGroups: z.number(), }) -export const AWAY_ACTIONS = ['get', 'set', 'clear'] as const -export type AwayAction = (typeof AWAY_ACTIONS)[number] - -/** - * Schema for away tool output - */ -export const AwayOutputSchema = z.object({ - type: z.literal('away_status'), - action: z.enum(AWAY_ACTIONS), - isAway: z.boolean(), - awayMode: z - .object({ - type: z.string(), - dateFrom: z.string(), - dateTo: z.string(), - }) - .optional(), -}) - /** - * Schema for user-info tool output + * Schema for user-info tool output. `name` mirrors `User.fullName`. */ export const UserInfoOutputSchema = z.object({ type: z.literal('user_info'), userId: z.number(), name: z.string(), + shortName: z.string(), email: z.string(), timezone: z.string(), - bot: z.boolean(), - defaultWorkspace: z.number().nullable(), + lang: z.string(), }) /** @@ -303,11 +285,11 @@ export const BuildLinkOutputSchema = z.object({ linkType: z.enum(['conversation', 'message', 'thread', 'comment']), params: z.object({ workspaceId: z.number(), - conversationId: z.number().optional(), - messageId: z.union([z.number(), z.string()]).optional(), - channelId: z.number().optional(), - threadId: z.number().optional(), - commentId: z.union([z.number(), z.string()]).optional(), + conversationId: z.string().optional(), + messageId: z.string().optional(), + channelId: z.string().optional(), + threadId: z.string().optional(), + commentId: z.string().optional(), }), }) @@ -317,16 +299,16 @@ export const BuildLinkOutputSchema = z.object({ export const CreateThreadOutputSchema = z.object({ type: z.literal('create_thread_result'), success: z.boolean(), - threadId: z.number(), + threadId: z.string(), title: z.string(), - channelId: z.number(), + channelId: z.string(), workspaceId: z.number(), content: z.string(), creator: z.number(), created: z.string(), threadUrl: z.string(), recipients: z.array(z.number()).optional(), - groups: z.array(z.number()).optional(), + groups: z.array(z.string()).optional(), }) /** @@ -335,9 +317,9 @@ export const CreateThreadOutputSchema = z.object({ export const UpdateThreadOutputSchema = z.object({ type: z.literal('update_thread_result'), success: z.boolean(), - threadId: z.number(), + threadId: z.string(), title: z.string(), - channelId: z.number(), + channelId: z.string(), workspaceId: z.number(), content: z.string(), threadUrl: z.string(), @@ -350,9 +332,9 @@ export const UpdateThreadOutputSchema = z.object({ export const UpdateCommentOutputSchema = z.object({ type: z.literal('update_comment_result'), success: z.boolean(), - commentId: z.number(), - threadId: z.number(), - channelId: z.number(), + commentId: z.string(), + threadId: z.string(), + channelId: z.string(), workspaceId: z.number(), content: z.string(), commentUrl: z.string(), @@ -365,8 +347,8 @@ export const UpdateCommentOutputSchema = z.object({ export const UpdateMessageOutputSchema = z.object({ type: z.literal('update_message_result'), success: z.boolean(), - messageId: z.number(), - conversationId: z.number(), + messageId: z.string(), + conversationId: z.string(), workspaceId: z.number(), content: z.string(), messageUrl: z.string(), @@ -391,16 +373,16 @@ export const UpdateObjectOutputSchema = z.object({ workspaceId: z.number(), lastEdited: z.string().nullable().optional(), // thread fields - threadId: z.number().optional(), + threadId: z.string().optional(), title: z.string().optional(), - channelId: z.number().optional(), + channelId: z.string().optional(), threadUrl: z.string().optional(), // comment fields - commentId: z.number().optional(), + commentId: z.string().optional(), commentUrl: z.string().optional(), // message fields - messageId: z.number().optional(), - conversationId: z.number().optional(), + messageId: z.string().optional(), + conversationId: z.string().optional(), messageUrl: z.string().optional(), }) @@ -411,7 +393,7 @@ export const DeleteThreadOutputSchema = z.object({ type: z.literal('delete_thread_result'), success: z.boolean(), targetType: z.literal('thread'), - threadId: z.number(), + threadId: z.string(), }) /** @@ -421,7 +403,7 @@ export const DeleteCommentOutputSchema = z.object({ type: z.literal('delete_comment_result'), success: z.boolean(), targetType: z.literal('comment'), - commentId: z.number(), + commentId: z.string(), }) /** @@ -431,13 +413,13 @@ export const DeleteMessageOutputSchema = z.object({ type: z.literal('delete_message_result'), success: z.boolean(), targetType: z.literal('message'), - messageId: z.number(), + messageId: z.string(), }) /** * Schema for delete-object tool output. * - * The Twist SDK delete endpoints return no body, so the structured payload simply + * The Comms SDK delete endpoints return no body, so the structured payload simply * confirms which object was deleted. The `type` discriminator selects which * id field is populated: * - `delete_thread_result` → threadId @@ -448,9 +430,9 @@ export const DeleteObjectOutputSchema = z.object({ type: z.enum(['delete_thread_result', 'delete_comment_result', 'delete_message_result']), success: z.boolean(), targetType: z.enum(['thread', 'comment', 'message']), - threadId: z.number().optional(), - commentId: z.number().optional(), - messageId: z.number().optional(), + threadId: z.string().optional(), + commentId: z.string().optional(), + messageId: z.string().optional(), }) /** @@ -460,14 +442,14 @@ export const ReplyOutputSchema = z.object({ type: z.literal('reply_result'), success: z.boolean(), targetType: z.enum(['thread', 'conversation']), - targetId: z.number(), - replyId: z.number(), + targetId: z.string(), + replyId: z.string(), content: z.string(), created: z.string(), replyUrl: z.string(), recipients: z.array(z.number()).optional(), notifyAudience: z.enum(NOTIFY_AUDIENCES).optional(), - groups: z.array(z.number()).optional(), + groups: z.array(z.string()).optional(), }) /** @@ -478,7 +460,7 @@ export const ReactOutputSchema = z.object({ success: z.boolean(), operation: z.enum(['add', 'remove']), targetType: z.enum(['thread', 'comment', 'message']), - targetId: z.number(), + targetId: z.string(), emoji: z.string(), targetUrl: z.string(), }) @@ -490,10 +472,10 @@ export const MarkDoneOutputSchema = z.object({ type: z.literal('mark_done_result'), itemType: z.enum(['thread', 'conversation']), mode: z.enum(['individual', 'bulk']), - completed: z.array(z.number()), + completed: z.array(z.string()), failed: z.array( z.object({ - item: z.number(), + item: z.string(), error: z.string(), }), ), @@ -508,7 +490,7 @@ export const MarkDoneOutputSchema = z.object({ selectors: z .object({ workspaceId: z.number().optional(), - channelId: z.number().optional(), + channelId: z.string().optional(), }) .optional(), }) @@ -521,7 +503,7 @@ export const ListChannelsOutputSchema = z.object({ workspaceId: z.number(), channels: z.array( z.object({ - id: z.number(), + id: z.string(), name: z.string(), description: z.string().optional(), public: z.boolean(), @@ -540,7 +522,6 @@ export const ListChannelsOutputSchema = z.object({ * Union of all possible structured outputs for type safety */ export const StructuredOutputSchema = z.union([ - AwayOutputSchema, LoadThreadOutputSchema, LoadConversationOutputSchema, FetchInboxOutputSchema, @@ -587,7 +568,6 @@ export type DeleteObjectOutput = z.infer * to construct structured payloads — `DeleteObjectOutput` is the looser MCP-facing shape. */ export type DeleteObjectStructured = DeleteThreadOutput | DeleteCommentOutput | DeleteMessageOutput -export type AwayOutput = z.infer export type LoadThreadOutput = z.infer export type LoadConversationOutput = z.infer export type FetchInboxOutput = z.infer diff --git a/src/utils/required-tool-annotations.ts b/src/utils/required-tool-annotations.ts index 2596b30..5b4df86 100644 --- a/src/utils/required-tool-annotations.ts +++ b/src/utils/required-tool-annotations.ts @@ -13,7 +13,7 @@ function formatToolTitle(toolName: string): string { .map((segment) => `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`) .join(' ') - return `Twist: ${formatted}` + return `Comms: ${formatted}` } export { formatToolTitle, type RequiredToolAnnotations } diff --git a/src/utils/target-types.ts b/src/utils/target-types.ts index 92dca15..f27a8d9 100644 --- a/src/utils/target-types.ts +++ b/src/utils/target-types.ts @@ -1,17 +1,17 @@ import { z } from 'zod' /** - * Valid Twist object types that can be used in enums + * Valid Comms object types that can be used in enums */ -type TwistObjectType = 'thread' | 'comment' | 'message' | 'conversation' | 'workspace' +type CommsObjectType = 'thread' | 'comment' | 'message' | 'conversation' | 'workspace' /** * Helper to create an enum schema with types. * Requires at least 2 values (Zod enum requirement). - * Only allows valid Twist object types. + * Only allows valid Comms object types. */ function createEnumSchema< - const T extends readonly [TwistObjectType, TwistObjectType, ...TwistObjectType[]], + const T extends readonly [CommsObjectType, CommsObjectType, ...CommsObjectType[]], >(values: T) { return { values, diff --git a/src/utils/test-helpers.ts b/src/utils/test-helpers.ts index 780c65a..eb7a877 100644 --- a/src/utils/test-helpers.ts +++ b/src/utils/test-helpers.ts @@ -5,7 +5,7 @@ import type { Thread, User, Workspace, -} from '@doist/twist-sdk' +} from '@doist/comms-sdk' import type { getToolOutput } from '../mcp-helpers.js' /** @@ -14,17 +14,17 @@ import type { getToolOutput } from '../mcp-helpers.js' */ export function createMockThread(overrides: Partial = {}): Thread { return { - id: 12345, + id: TEST_IDS.THREAD_1, title: 'Test Thread', content: 'Test thread content', - channelId: 67890, - workspaceId: 11111, - creator: 22222, + channelId: TEST_IDS.CHANNEL_1, + workspaceId: TEST_IDS.WORKSPACE_1, + creator: TEST_IDS.USER_1, posted: new Date('2024-01-01T00:00:00Z'), lastUpdated: new Date('2024-01-01T00:00:00Z'), pinned: false, snippet: 'Test thread content', - snippetCreator: 22222, + snippetCreator: TEST_IDS.USER_1, systemMessage: null, attachments: [], groups: [], @@ -33,9 +33,8 @@ export function createMockThread(overrides: Partial = {}): Thread { commentCount: 0, isArchived: false, inInbox: true, - starred: false, - participants: [22222], - url: 'https://twist.com/a/11111/ch/67890/t/12345/', + participants: [TEST_IDS.USER_1], + url: `https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_1}/`, ...overrides, } } @@ -46,18 +45,18 @@ export function createMockThread(overrides: Partial = {}): Thread { */ export function createMockComment(overrides: Partial = {}): Comment { return { - id: 54321, + id: TEST_IDS.COMMENT_1, content: 'Test comment content', - threadId: 12345, - workspaceId: 11111, - channelId: 67890, - creator: 22222, + threadId: TEST_IDS.THREAD_1, + workspaceId: TEST_IDS.WORKSPACE_1, + channelId: TEST_IDS.CHANNEL_1, + creator: TEST_IDS.USER_1, posted: new Date('2024-01-01T00:00:00Z'), systemMessage: null, attachments: [], reactions: {}, objIndex: 1, - url: 'https://twist.com/a/11111/ch/67890/t/12345/c/54321', + url: `https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_1}/c/${TEST_IDS.COMMENT_1}`, ...overrides, } } @@ -68,18 +67,18 @@ export function createMockComment(overrides: Partial = {}): Comment { */ export function createMockConversation(overrides: Partial = {}): Conversation { return { - id: 33333, - workspaceId: 11111, - userIds: [22222, 44444], + id: TEST_IDS.CONVERSATION_1, + workspaceId: TEST_IDS.WORKSPACE_1, + userIds: [TEST_IDS.USER_1, TEST_IDS.USER_2], messageCount: 0, lastObjIndex: 0, snippet: '', snippetCreators: [], archived: false, - creator: 22222, + creator: TEST_IDS.USER_1, created: new Date('2024-01-01T00:00:00Z'), lastActive: new Date('2024-01-01T00:00:00Z'), - url: 'https://twist.com/a/11111/msg/33333/', + url: `https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_1}/`, ...overrides, } } @@ -92,18 +91,18 @@ export function createMockConversationMessage( overrides: Partial = {}, ): ConversationMessage { return { - id: 98765, + id: TEST_IDS.MESSAGE_1, content: 'Test message content', - creator: 22222, - conversationId: 33333, - workspaceId: 11111, + creator: TEST_IDS.USER_1, + conversationId: TEST_IDS.CONVERSATION_1, + workspaceId: TEST_IDS.WORKSPACE_1, posted: new Date('2024-01-01T00:00:00Z'), systemMessage: null, attachments: [], reactions: {}, objIndex: 1, lastEdited: null, - url: 'https://twist.com/a/11111/msg/33333/m/98765', + url: `https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_1}/m/${TEST_IDS.MESSAGE_1}`, ...overrides, } } @@ -116,17 +115,9 @@ export function createMockUser(overrides: Partial = {}): User { return { id: TEST_IDS.USER_1, email: 'test@example.com', - name: 'Test User', + fullName: 'Test User', shortName: 'Test', - avatarId: undefined, - defaultWorkspace: TEST_IDS.WORKSPACE_1, - awayMode: undefined, - profession: undefined, - contactInfo: undefined, timezone: 'UTC', - snoozeUntil: undefined, - offDays: [], - bot: false, lang: 'en', removed: false, ...overrides, @@ -143,11 +134,8 @@ export function createMockWorkspace(overrides: Partial = {}): Workspa name: 'Test Workspace', creator: TEST_IDS.USER_1, created: new Date('2024-01-01T00:00:00Z'), - defaultChannel: TEST_IDS.CHANNEL_1, defaultConversation: TEST_IDS.CONVERSATION_1, plan: 'free', - avatarId: undefined, - avatarUrls: undefined, ...overrides, } } @@ -223,20 +211,27 @@ export function extractStructuredContent( /** * Common mock IDs used across tests for consistency. + * + * Channel/thread/comment/conversation/message/group IDs are opaque base58 + * UUIDv7 strings in the Comms API; workspace/user IDs are numeric. + * The string IDs below are stable fixtures — not real base58 — and exist + * purely so tests have predictable values to assert on. */ export const TEST_IDS = { - THREAD_1: 12345, - THREAD_2: 12346, - THREAD_3: 12347, - COMMENT_1: 54321, - COMMENT_2: 54322, - CONVERSATION_1: 33333, - CONVERSATION_2: 33334, - MESSAGE_1: 98765, - MESSAGE_2: 98766, + THREAD_1: 'thread-id-1', + THREAD_2: 'thread-id-2', + THREAD_3: 'thread-id-3', + COMMENT_1: 'comment-id-1', + COMMENT_2: 'comment-id-2', + CONVERSATION_1: 'conv-id-1', + CONVERSATION_2: 'conv-id-2', + MESSAGE_1: 'msg-id-1', + MESSAGE_2: 'msg-id-2', + CHANNEL_1: 'channel-id-1', + GROUP_1: 'group-id-1', + GROUP_2: 'group-id-2', WORKSPACE_1: 11111, WORKSPACE_2: 11112, - CHANNEL_1: 67890, USER_1: 22222, USER_2: 44444, USER_3: 55555, diff --git a/src/utils/tool-names.ts b/src/utils/tool-names.ts index ff5648b..fa220ee 100644 --- a/src/utils/tool-names.ts +++ b/src/utils/tool-names.ts @@ -15,6 +15,5 @@ export const ToolNames = { GET_WORKSPACES: 'get-workspaces', GET_USERS: 'get-users', GET_GROUPS: 'get-groups', - AWAY: 'away', LIST_CHANNELS: 'list-channels', } as const diff --git a/src/utils/url-helpers.ts b/src/utils/url-helpers.ts index ad696d3..0f5b497 100644 --- a/src/utils/url-helpers.ts +++ b/src/utils/url-helpers.ts @@ -1,13 +1,13 @@ -import { getFullTwistURL } from '@doist/twist-sdk' +import { getFullCommsURL } from '@doist/comms-sdk' export function getWorkspaceUrl(workspaceId: number): string { - return getFullTwistURL({ workspaceId }) + return getFullCommsURL({ workspaceId }) } -export function getChannelUrl(workspaceId: number, channelId: number): string { - return getFullTwistURL({ workspaceId, channelId }) +export function getChannelUrl(workspaceId: number, channelId: string): string { + return getFullCommsURL({ workspaceId, channelId }) } -export function getConversationUrl(workspaceId: number, conversationId: number): string { - return getFullTwistURL({ workspaceId, conversationId }) +export function getConversationUrl(workspaceId: number, conversationId: string): string { + return getFullCommsURL({ workspaceId, conversationId }) }