From 18c5dd9430e746945af27f9597baea80e53fd622 Mon Sep 17 00:00:00 2001 From: Rain S <5832662+rainsjm@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:48:49 +0100 Subject: [PATCH 1/4] chore(deps): bump @doist/comms-sdk to 1.0.1 0.9.0's `getConversations` accepts only `{workspaceId, archived}`, so `list-conversations` could never page past the server's default window. 1.0.x adds `limit` and the compound `(olderThan, beforeId)` cursor, and converts `olderThan` to `older_than_ts` internally rather than letting a Date reach the generic snake-casing. The only breaking change in 1.0.0 is `node >=24` / `npm >=11`, which this repo already requires (CI runs 24 and 26). Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 11 ++++++----- package.json | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2aad4e5..695b444 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "6.2.0", "license": "MIT", "dependencies": { - "@doist/comms-sdk": "0.9.0", + "@doist/comms-sdk": "1.0.1", "dotenv": "17.4.2", "p-limit": "6.2.0", "zod": "4.1.13" @@ -598,9 +598,9 @@ } }, "node_modules/@doist/comms-sdk": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-0.9.0.tgz", - "integrity": "sha512-JD8I27I3HPHHOq/FjH534WT1hINDBCWDWFH3ylD6M56lAJbo2ztToZ/2RfxNrBrXGHkRP01MgvfZP1GIQmSEXA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-1.0.1.tgz", + "integrity": "sha512-DTlLee6oBnN8SsjzTBD8ThqEneszt1L6JU/U4GfjZEk60bxNWSQVbFqgBBpn9fUNL5s+si+x6aX/QY0D2fps9g==", "license": "MIT", "dependencies": { "camelcase": "9.0.0", @@ -610,7 +610,8 @@ "zod": "4.4.3" }, "engines": { - "node": ">=20.18.1" + "node": ">=24", + "npm": ">=11" } }, "node_modules/@doist/comms-sdk/node_modules/zod": { diff --git a/package.json b/package.json index e12bd26..2fa05d3 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "prepublishOnly": "npm run build && npm test" }, "dependencies": { - "@doist/comms-sdk": "0.9.0", + "@doist/comms-sdk": "1.0.1", "dotenv": "17.4.2", "p-limit": "6.2.0", "zod": "4.1.13" From efde67fd65ce29d7bdc7145f21e3d87082429b6d Mon Sep 17 00:00:00 2001 From: Rain S <5832662+rainsjm@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:50:18 +0100 Subject: [PATCH 2/4] feat(list-conversations): paginate and filter by participants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool fetched a single unpaginated page, so it could only ever see the first ~20 conversations in a workspace. Anything further down was invisible, and the short list was indistinguishable from a complete one — an automation looking for a specific DM would silently conclude it did not exist. Adds the repo's standard `limit` (1..100, default 50) and an opaque `cursor`, returning `hasMore` alongside them, matching the contract search-content and get-mentions already use. The server's cursor is the compound (lastActive, id) of the last row; that is base64-encoded into a single string so callers never assemble it by hand. Adds `userIds` + `matchMode` to look a conversation up by who is in it. `exact` (the default) matches the participant set of you plus userIds — the same rule the backend dedupes on, so it agrees with what create-conversation resolves for the same people, and an empty array finds the conversation containing only you. `includes` returns every conversation containing those users. Filtered lookups walk the pages internally, so a caller gets an answer in one call rather than paging and deciding when to stop. Three guards keep a partial result from passing as a complete one: - A full page yielding no unseen rows means the cursor has stopped advancing, and throws rather than truncating. - The scan refuses to run past MAX_SCAN_PAGES. - Exhaustion is judged by a page adding nothing new, never by a page coming back shorter than requested. The server is free to cap a page below SCAN_PAGE_SIZE, and reading its cap as the end of the list would end every scan at the first page — reintroducing the bug this fixes. The cost is one extra request per scan. The caller-driven path does still assume `limit` is honoured; that assumption is stated where it is made. A capped `includes` scan resumes from the row it stopped on, not the end of that row's page, which would skip everything in between. Also fixes archived handling. `includeArchived: false` sent no `archived` param at all, which returns the server's unfiltered stream — so archived conversations leaked into the default listing. `includeArchived: true` then fetched that same unfiltered stream *plus* an archived-only one and concatenated them, double-counting every archived row. Now false sends `archived: false` and true omits it, which also keeps pagination on a single cursor. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/list-conversations.test.ts | 347 ++++++++++++++++++++++++++- src/tools/list-conversations.ts | 309 ++++++++++++++++++++++-- src/utils/output-schemas.ts | 2 + 3 files changed, 629 insertions(+), 29 deletions(-) diff --git a/src/tools/list-conversations.test.ts b/src/tools/list-conversations.test.ts index e6a57c6..546971c 100644 --- a/src/tools/list-conversations.test.ts +++ b/src/tools/list-conversations.test.ts @@ -18,6 +18,9 @@ const mockCommsApi = { getUserById: jest.fn(), getWorkspaceUsers: jest.fn(), }, + users: { + getSessionUser: jest.fn(), + }, } as unknown as jest.Mocked const { LIST_CONVERSATIONS } = ToolNames @@ -25,6 +28,7 @@ const { LIST_CONVERSATIONS } = ToolNames describe(`${LIST_CONVERSATIONS} tool`, () => { beforeEach(() => { jest.clearAllMocks() + mockCommsApi.users.getSessionUser.mockResolvedValue({ id: TEST_IDS.USER_1 } as never) }) describe('listing conversations', () => { @@ -60,6 +64,8 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { expect(mockCommsApi.conversations.getConversations).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, + archived: false, + limit: 50, }) const textContent = extractTextContent(result) @@ -75,6 +81,7 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { type: 'list_conversations', workspaceId: TEST_IDS.WORKSPACE_1, totalConversations: 2, + hasMore: false, conversations: expect.arrayContaining([ expect.objectContaining({ id: TEST_IDS.CONVERSATION_1, @@ -110,6 +117,7 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, conversations: [], totalConversations: 0, + hasMore: false, }) }) @@ -386,7 +394,7 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { }) describe('includeArchived', () => { - it('should only fetch active conversations by default', async () => { + it('should request only active conversations by default', async () => { mockCommsApi.conversations.getConversations.mockResolvedValue([ createMockConversation(), ]) @@ -396,13 +404,17 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { await listConversations.execute({ workspaceId: TEST_IDS.WORKSPACE_1 }, mockCommsApi) + // `archived: false` is sent explicitly: omitting it returns the server's + // unfiltered stream, which includes archived conversations. expect(mockCommsApi.conversations.getConversations).toHaveBeenCalledTimes(1) expect(mockCommsApi.conversations.getConversations).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, + archived: false, + limit: 50, }) }) - it('should fetch active and archived conversations in parallel when includeArchived is true', async () => { + it('should fetch one combined stream when includeArchived is true', async () => { const activeConversation = createMockConversation({ id: TEST_IDS.CONVERSATION_1, title: 'Active Chat', @@ -413,12 +425,10 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { archived: true, }) - mockCommsApi.conversations.getConversations.mockImplementation(async (args) => { - if ('archived' in args && args.archived === true) { - return [archivedConversation] - } - return [activeConversation] - }) + mockCommsApi.conversations.getConversations.mockResolvedValue([ + activeConversation, + archivedConversation, + ]) mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ fullName: 'Alice', } as never) @@ -428,13 +438,13 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { mockCommsApi, ) - expect(mockCommsApi.conversations.getConversations).toHaveBeenCalledTimes(2) - expect(mockCommsApi.conversations.getConversations).toHaveBeenCalledWith({ - workspaceId: TEST_IDS.WORKSPACE_1, - }) + // Omitting `archived` returns both states in a single stream, which keeps + // one cursor. Two filtered requests would need two, and concatenating them + // would double-count archived rows. + expect(mockCommsApi.conversations.getConversations).toHaveBeenCalledTimes(1) expect(mockCommsApi.conversations.getConversations).toHaveBeenCalledWith({ workspaceId: TEST_IDS.WORKSPACE_1, - archived: true, + limit: 50, }) const structuredContent = extractStructuredContent(result) @@ -448,6 +458,317 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { }) }) + describe('pagination', () => { + it('should report hasMore and return a cursor when the page is full', async () => { + const page = Array.from({ length: 3 }, (_, i) => + createMockConversation({ + id: `conv-${i}`, + userIds: [TEST_IDS.USER_1], + lastActive: new Date(`2024-01-0${i + 1}T00:00:00Z`), + }), + ) + mockCommsApi.conversations.getConversations.mockResolvedValue(page) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice', + } as never) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, limit: 3 }, + mockCommsApi, + ) + + const structuredContent = extractStructuredContent(result) + expect(structuredContent.hasMore).toBe(true) + expect(structuredContent.cursor).toEqual(expect.any(String)) + + const textContent = extractTextContent(result) + expect(textContent).toContain('More results available.') + }) + + it('should not report hasMore when the page is short', async () => { + mockCommsApi.conversations.getConversations.mockResolvedValue([ + createMockConversation(), + ]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice', + } as never) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50 }, + mockCommsApi, + ) + + const structuredContent = extractStructuredContent(result) + expect(structuredContent.hasMore).toBe(false) + expect(structuredContent).not.toHaveProperty('cursor') + expect(extractTextContent(result)).not.toContain('More results available.') + }) + + it('should resume from a returned cursor via the compound (lastActive, id) key', async () => { + const boundary = createMockConversation({ + id: 'conv-boundary', + userIds: [TEST_IDS.USER_1], + lastActive: new Date('2024-03-04T05:06:07Z'), + }) + mockCommsApi.conversations.getConversations.mockResolvedValue([boundary]) + mockCommsApi.workspaceUsers.getUserById.mockResolvedValue({ + fullName: 'Alice', + } as never) + + const first = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, limit: 1 }, + mockCommsApi, + ) + const cursor = extractStructuredContent(first).cursor as string + + await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, limit: 1, cursor }, + mockCommsApi, + ) + + expect(mockCommsApi.conversations.getConversations).toHaveBeenLastCalledWith({ + workspaceId: TEST_IDS.WORKSPACE_1, + archived: false, + limit: 1, + olderThan: new Date('2024-03-04T05:06:07Z'), + beforeId: 'conv-boundary', + }) + }) + + it('should reject a malformed cursor rather than silently restarting', async () => { + await expect( + listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, cursor: 'not-a-real-cursor' }, + mockCommsApi, + ), + ).rejects.toThrow('Invalid cursor') + + expect(mockCommsApi.conversations.getConversations).not.toHaveBeenCalled() + }) + }) + + describe('participant filtering', () => { + const alice = TEST_IDS.USER_1 + const bob = TEST_IDS.USER_2 + const carol = TEST_IDS.USER_3 + + beforeEach(() => { + mockCommsApi.workspaceUsers.getUserById.mockImplementation( + async (args: { workspaceId: number; userId: number }) => + ({ fullName: `User ${args.userId}` }) as never, + ) + }) + + it('should find the group conversation with exactly the given participants', async () => { + const groupWithBobAndCarol = createMockConversation({ + id: 'conv-group', + userIds: [alice, bob, carol], + }) + mockCommsApi.conversations.getConversations.mockResolvedValue([ + createMockConversation({ id: 'conv-bob', userIds: [alice, bob] }), + createMockConversation({ id: 'conv-bigger', userIds: [alice, bob, carol, 99999] }), + groupWithBobAndCarol, + ]) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [bob, carol] }, + mockCommsApi, + ) + + const structuredContent = extractStructuredContent(result) + expect(structuredContent.totalConversations).toBe(1) + expect(structuredContent.conversations[0]).toMatchObject({ id: 'conv-group' }) + }) + + it('should resume a capped includes scan from the matched row, not the end of its page', async () => { + // Three rows in one page, two of them matches, limit 1. Resuming from the + // end of the page would skip conv-middle and conv-last entirely. + const firstMatch = createMockConversation({ + id: 'conv-first', + userIds: [alice, bob], + lastActive: new Date('2024-05-01T00:00:00Z'), + }) + mockCommsApi.conversations.getConversations.mockResolvedValue([ + firstMatch, + createMockConversation({ + id: 'conv-middle', + userIds: [alice, carol], + lastActive: new Date('2024-05-02T00:00:00Z'), + }), + createMockConversation({ + id: 'conv-last', + userIds: [alice, bob], + lastActive: new Date('2024-05-03T00:00:00Z'), + }), + ]) + + const first = await listConversations.execute( + { + workspaceId: TEST_IDS.WORKSPACE_1, + userIds: [bob], + matchMode: 'includes', + limit: 1, + }, + mockCommsApi, + ) + + const structuredContent = extractStructuredContent(first) + expect(structuredContent.hasMore).toBe(true) + + await listConversations.execute( + { + workspaceId: TEST_IDS.WORKSPACE_1, + userIds: [bob], + matchMode: 'includes', + limit: 1, + cursor: structuredContent.cursor as string, + }, + mockCommsApi, + ) + + expect(mockCommsApi.conversations.getConversations).toHaveBeenLastCalledWith( + expect.objectContaining({ + olderThan: new Date('2024-05-01T00:00:00Z'), + beforeId: 'conv-first', + }), + ) + }) + + it('should keep scanning past a page shorter than the requested size', async () => { + // The server may cap the page below what we asked for. Treating a short + // page as the end of the list would stop every scan at the first page. + const target = createMockConversation({ id: 'conv-target', userIds: [alice, bob] }) + + mockCommsApi.conversations.getConversations + .mockResolvedValueOnce([ + createMockConversation({ id: 'conv-other', userIds: [alice, carol] }), + ]) + .mockResolvedValueOnce([target]) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [bob] }, + mockCommsApi, + ) + + expect(mockCommsApi.conversations.getConversations).toHaveBeenCalledTimes(2) + expect(extractStructuredContent(result).conversations[0]).toMatchObject({ + id: 'conv-target', + }) + }) + + it('should match participants regardless of order', async () => { + mockCommsApi.conversations.getConversations.mockResolvedValue([ + createMockConversation({ id: 'conv-group', userIds: [carol, alice, bob] }), + ]) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [bob, carol] }, + mockCommsApi, + ) + + expect(extractStructuredContent(result).totalConversations).toBe(1) + }) + + it('should find the conversation containing only the session user for an empty array', async () => { + mockCommsApi.conversations.getConversations.mockResolvedValue([ + createMockConversation({ id: 'conv-bob', userIds: [alice, bob] }), + createMockConversation({ id: 'conv-self', userIds: [alice] }), + ]) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [] }, + mockCommsApi, + ) + + const structuredContent = extractStructuredContent(result) + expect(structuredContent.totalConversations).toBe(1) + expect(structuredContent.conversations[0]).toMatchObject({ id: 'conv-self' }) + }) + + it('should return every conversation containing the participants in includes mode', async () => { + mockCommsApi.conversations.getConversations.mockResolvedValue([ + createMockConversation({ id: 'conv-bob', userIds: [alice, bob] }), + createMockConversation({ id: 'conv-group', userIds: [alice, bob, carol] }), + createMockConversation({ id: 'conv-carol', userIds: [alice, carol] }), + ]) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [bob], matchMode: 'includes' }, + mockCommsApi, + ) + + const structuredContent = extractStructuredContent(result) + expect(structuredContent.totalConversations).toBe(2) + expect(structuredContent.conversations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'conv-bob' }), + expect.objectContaining({ id: 'conv-group' }), + ]), + ) + }) + + it('should walk past the first page to reach a match further down', async () => { + // A full first page forces a second request; the match only appears there. + const firstPage = Array.from({ length: 500 }, (_, i) => + createMockConversation({ + id: `filler-${i}`, + userIds: [alice, 90000 + i], + lastActive: new Date(2024, 0, 1, 0, 0, i), + }), + ) + const target = createMockConversation({ id: 'conv-target', userIds: [alice, bob] }) + + mockCommsApi.conversations.getConversations + .mockResolvedValueOnce(firstPage) + .mockResolvedValueOnce([target]) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [bob] }, + mockCommsApi, + ) + + expect(mockCommsApi.conversations.getConversations).toHaveBeenCalledTimes(2) + const structuredContent = extractStructuredContent(result) + expect(structuredContent.conversations[0]).toMatchObject({ id: 'conv-target' }) + }) + + it('should report no match distinctly from an empty workspace', async () => { + mockCommsApi.conversations.getConversations.mockResolvedValue([ + createMockConversation({ id: 'conv-bob', userIds: [alice, bob] }), + ]) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [carol] }, + mockCommsApi, + ) + + expect(extractTextContent(result)).toContain( + 'No conversation matches those participants', + ) + expect(extractStructuredContent(result).totalConversations).toBe(0) + }) + + it('should throw rather than truncate when the cursor stops advancing', async () => { + // A full page of already-seen rows means the cursor is stuck. Returning + // what we have would look identical to "no such conversation". + const stuckPage = Array.from({ length: 500 }, (_, i) => + createMockConversation({ + id: `filler-${i}`, + userIds: [alice, 90000 + i], + lastActive: new Date(2024, 0, 1, 0, 0, i), + }), + ) + mockCommsApi.conversations.getConversations.mockResolvedValue(stuckPage) + + await expect( + listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [bob] }, + mockCommsApi, + ), + ).rejects.toThrow('results would be incomplete') + }) + }) + describe('error handling', () => { it('should propagate API errors', async () => { const apiError = new Error(TEST_ERRORS.API_UNAUTHORIZED) diff --git a/src/tools/list-conversations.ts b/src/tools/list-conversations.ts index 2862f59..f992c8b 100644 --- a/src/tools/list-conversations.ts +++ b/src/tools/list-conversations.ts @@ -7,6 +7,8 @@ import { ListConversationsOutputSchema } from '../utils/output-schemas.js' import { ToolNames } from '../utils/tool-names.js' import { getConversationUrl } from '../utils/url-helpers.js' +const MATCH_MODES = ['exact', 'includes'] as const + const ArgsSchema = { workspaceId: z.number().describe('The workspace ID to list conversations from.'), includeArchived: z @@ -15,6 +17,28 @@ const ArgsSchema = { .describe( 'Whether to include archived conversations. If true, both active and archived conversations are returned. Defaults to false (active conversations only).', ), + userIds: z + .array(z.number()) + .optional() + .describe( + 'Filter to conversations with these participants, excluding yourself (you are always implied). An empty array matches the conversation containing only you. Omit to list without filtering. Use get-users to resolve names to IDs.', + ), + matchMode: z + .enum(MATCH_MODES) + .optional() + .default('exact') + .describe( + 'How userIds is matched. "exact" (default) returns the single conversation whose participants are exactly you plus userIds — use this to find a specific direct or group conversation. "includes" returns every conversation containing all of userIds, and possibly others.', + ), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .default(50) + .describe('Maximum number of conversations to return.'), + cursor: z.string().optional().describe('Cursor for pagination.'), } type ConversationData = { @@ -34,6 +58,8 @@ type ListConversationsStructured = Record & { workspaceId: number conversations: ConversationData[] totalConversations: number + hasMore: boolean + cursor?: string } // Only resolve names for the first few participants of each conversation. A large @@ -47,6 +73,230 @@ const MAX_DISPLAYED_PARTICIPANTS = 5 // lookups avoid pulling the whole roster for a handful of names. const PARTICIPANT_ROSTER_THRESHOLD = 20 +// Page size used when scanning for a participant match. Larger than the caller's +// `limit` because these pages are walked internally and never rendered — only the +// matches are. The server caps the page itself, so this is an upper bound. +const SCAN_PAGE_SIZE = 500 + +// Runaway guard for the internal scan. At SCAN_PAGE_SIZE this covers workspaces far +// larger than any real one; hitting it means something is wrong, and reporting a +// partial result would be indistinguishable from "no such conversation". +const MAX_SCAN_PAGES = 50 + +type PageCursor = { olderThan: Date; beforeId: string } + +// The server's cursor is the compound (lastActive, id) of the last row seen, but +// callers get a single opaque string — matching the cursor contract of +// search-content and get-mentions, and keeping the compound shape an +// implementation detail rather than something an LLM has to assemble by hand. +function encodeCursor(conversation: Conversation): string { + const payload = JSON.stringify({ + olderThan: conversation.lastActive.toISOString(), + beforeId: conversation.id, + }) + return Buffer.from(payload, 'utf8').toString('base64url') +} + +const CursorSchema = z.object({ olderThan: z.iso.datetime(), beforeId: z.string().min(1) }) + +function decodeCursor(raw: string): PageCursor { + let parsed: unknown + try { + parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) + } catch { + parsed = undefined + } + + const result = CursorSchema.safeParse(parsed) + if (!result.success) { + throw new Error('Invalid cursor: expected a cursor returned by a previous call.') + } + + return { olderThan: new Date(result.data.olderThan), beforeId: result.data.beforeId } +} + +// Omitting `archived` entirely returns active and archived in one stream, which is +// what `includeArchived` wants — and it keeps a single cursor. Requesting the two +// states separately would need two independent cursors, and concatenating them +// would double-count archived rows. +function buildPageArgs( + workspaceId: number, + includeArchived: boolean, + limit: number, + cursor: PageCursor | undefined, +) { + return { + workspaceId, + limit, + ...(includeArchived ? {} : { archived: false }), + ...(cursor ? { olderThan: cursor.olderThan, beforeId: cursor.beforeId } : {}), + } +} + +/** + * Walk conversations from an optional starting cursor, yielding each row not seen + * on an earlier page. Each request continues from the previous page's last row via + * the compound (lastActive, id) cursor, so quiet conversations far down the list + * are still reached. + * + * Exhaustion is judged by a page adding nothing new, never by a page coming back + * shorter than requested: the server is free to cap the page below SCAN_PAGE_SIZE, + * and treating its cap as the end of the list would truncate every scan at the + * first page — the bug this iterator exists to fix. The cost is one extra request + * per scan. + */ +async function* iterateConversations( + client: CommsApi, + workspaceId: number, + includeArchived: boolean, + startCursor: PageCursor | undefined, +): AsyncGenerator { + const seenIds = new Set() + let cursor = startCursor + + for (let pageCount = 0; ; pageCount++) { + if (pageCount >= MAX_SCAN_PAGES) { + throw new Error( + `Scanned ${MAX_SCAN_PAGES} pages of conversations in workspace ${workspaceId} without exhausting the list; refusing to report a partial result.`, + ) + } + + const page = await client.conversations.getConversations( + buildPageArgs(workspaceId, includeArchived, SCAN_PAGE_SIZE, cursor), + ) + if (page.length === 0) return + + const unseen = page.filter((conversation) => !seenIds.has(conversation.id)) + if (unseen.length === 0) { + // Nothing new. On a full page that means the cursor has stopped + // advancing, and truncating silently is how conversations "disappear"; + // on a short one it just means the boundary row repeated at the end. + if (page.length >= SCAN_PAGE_SIZE) { + throw new Error( + `conversations/get returned a full page with no new conversations (workspace ${workspaceId}); results would be incomplete.`, + ) + } + return + } + + for (const conversation of unseen) { + seenIds.add(conversation.id) + yield conversation + } + + const last = page[page.length - 1] as Conversation + cursor = { olderThan: last.lastActive, beforeId: last.id } + } +} + +/** + * Build the participant predicate once per query rather than per conversation — + * `exact` needs a set to compare against, and rebuilding it for every row scanned + * is wasted work on a workspace-wide walk. + */ +function buildParticipantMatcher( + targetIds: readonly number[], + sessionUserId: number, + matchMode: (typeof MATCH_MODES)[number], +): (conversation: Conversation) => boolean { + if (matchMode === 'includes') { + return (conversation) => { + const participants = new Set(conversation.userIds) + return targetIds.every((id) => participants.has(id)) + } + } + + // Exact: the participant set is you plus the requested users, no one else. + // An empty `targetIds` therefore matches the conversation containing only you. + const expected = new Set([...targetIds, sessionUserId]) + return (conversation) => { + const participants = new Set(conversation.userIds) + return ( + participants.size === expected.size && [...expected].every((id) => participants.has(id)) + ) + } +} + +type ConversationQueryResult = { + conversations: Conversation[] + hasMore: boolean + nextCursor?: string +} + +/** Unfiltered listing: one page per call, the caller drives pagination. */ +async function fetchConversationPage( + client: CommsApi, + workspaceId: number, + includeArchived: boolean, + limit: number, + cursor: PageCursor | undefined, +): Promise { + const conversations = await client.conversations.getConversations( + buildPageArgs(workspaceId, includeArchived, limit, cursor), + ) + + // A short page means the list is exhausted; a full one means there may be more. + // This assumes the server honours `limit` — if it caps the page lower, a caller + // driving pagination stops early. The internal scan deliberately does not rely + // on that assumption; see iterateConversations. + const hasMore = conversations.length >= limit + const last = conversations[conversations.length - 1] + + return { + conversations, + hasMore, + ...(hasMore && last ? { nextCursor: encodeCursor(last) } : {}), + } +} + +/** + * Participant lookup: walk the pages internally and return only the matches, so a + * caller asking "the conversation with these people" gets an answer in one call + * rather than paging and deciding when to stop. + */ +async function scanForParticipants( + client: CommsApi, + workspaceId: number, + includeArchived: boolean, + targetIds: readonly number[], + matchMode: (typeof MATCH_MODES)[number], + limit: number, + cursor: PageCursor | undefined, +): Promise { + const sessionUser = await client.users.getSessionUser() + const matches: Conversation[] = [] + const matchesQuery = buildParticipantMatcher(targetIds, sessionUser.id, matchMode) + + for await (const conversation of iterateConversations( + client, + workspaceId, + includeArchived, + cursor, + )) { + if (!matchesQuery(conversation)) continue + + matches.push(conversation) + + // An exact participant set identifies at most one conversation — the same + // rule the backend dedupes on — so the first hit is the answer. + if (matchMode === 'exact') { + return { conversations: matches, hasMore: false } + } + + // Resume from the conversation we stopped on, not the end of its page — + // anything between the two would be skipped on the next call. + if (matches.length >= limit) { + return { + conversations: matches, + hasMore: true, + nextCursor: encodeCursor(conversation), + } + } + } + + return { conversations: matches, hasMore: false } +} + // Resolve user IDs to names. For a small number of IDs, look each up individually // (bounded concurrency, tolerating individual failures); for larger sets, fetch the // workspace roster once and resolve locally. IDs that can't be resolved are simply @@ -88,28 +338,20 @@ async function resolveParticipantNames( async function generateConversationsList( client: CommsApi, workspaceId: number, - includeArchived: boolean, + query: ConversationQueryResult, + emptyMessage: string, ): Promise<{ textContent: string; structuredContent: ListConversationsStructured }> { - // By default only fetch active conversations; optionally include archived ones too - let conversations: Conversation[] - if (includeArchived) { - const [active, archived] = await Promise.all([ - client.conversations.getConversations({ workspaceId }), - client.conversations.getConversations({ workspaceId, archived: true }), - ]) - conversations = [...active, ...archived] - } else { - conversations = await client.conversations.getConversations({ workspaceId }) - } + const { conversations, hasMore, nextCursor } = query if (conversations.length === 0) { return { - textContent: '# Conversations\n\nNo conversations found.', + textContent: `# Conversations\n\n${emptyMessage}`, structuredContent: { type: 'list_conversations', workspaceId, conversations: [], totalConversations: 0, + hasMore: false, }, } } @@ -172,6 +414,12 @@ async function generateConversationsList( lines.push('') } + if (hasMore) { + lines.push('## Next Steps') + lines.push('') + lines.push('More results available. Use the cursor to fetch the next page.') + } + const textContent = lines.join('\n') const structuredContent: ListConversationsStructured = { @@ -195,6 +443,8 @@ async function generateConversationsList( } }), totalConversations: conversations.length, + hasMore, + ...(nextCursor && { cursor: nextCursor }), } return { textContent, structuredContent } @@ -203,13 +453,40 @@ async function generateConversationsList( const listConversations = { name: ToolNames.LIST_CONVERSATIONS, description: - 'List conversations (direct messages) in a workspace. By default returns only active conversations; set includeArchived to true to also include archived conversations. Returns conversation IDs, titles, the full list of participant user IDs (with names resolved for the first few), archive status, last-active timestamps, snippets, and URLs.', + 'List conversations (direct messages) in a workspace, or find a specific one by its participants. Pass userIds to get the conversation with exactly those people (plus you) — an empty array finds the conversation with only you — or set matchMode to "includes" for every conversation containing them. Without userIds, returns a page of conversations; use the returned cursor for the next page. By default only active conversations are returned; set includeArchived to true to also include archived ones. Returns conversation IDs, titles, the full list of participant user IDs (with names resolved for the first few), archive status, last-active timestamps, snippets, and URLs.', parameters: ArgsSchema, outputSchema: ListConversationsOutputSchema.shape, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, async execute(args, client) { - const { workspaceId, includeArchived = false } = args - const result = await generateConversationsList(client, workspaceId, includeArchived) + const { + workspaceId, + includeArchived = false, + userIds, + matchMode = 'exact', + limit = 50, + cursor, + } = args + + const pageCursor = cursor ? decodeCursor(cursor) : undefined + + const query = userIds + ? await scanForParticipants( + client, + workspaceId, + includeArchived, + userIds, + matchMode, + limit, + pageCursor, + ) + : await fetchConversationPage(client, workspaceId, includeArchived, limit, pageCursor) + + const result = await generateConversationsList( + client, + workspaceId, + query, + userIds ? 'No conversation matches those participants.' : 'No conversations found.', + ) return getToolOutput({ textContent: result.textContent, diff --git a/src/utils/output-schemas.ts b/src/utils/output-schemas.ts index 644aa09..6164176 100644 --- a/src/utils/output-schemas.ts +++ b/src/utils/output-schemas.ts @@ -670,6 +670,8 @@ export const ListConversationsOutputSchema = z.object({ }), ), totalConversations: z.number(), + hasMore: z.boolean(), + cursor: z.string().optional(), }) /** From 67bc22299684d4f03d500cdb45cf3b9d53917a16 Mon Sep 17 00:00:00 2001 From: Rain S <5832662+rainsjm@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:10:09 +0100 Subject: [PATCH 3/4] fix(list-conversations): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Treat any repeated multi-row page as a stalled cursor, not exhaustion. The previous rule only threw on a page of SCAN_PAGE_SIZE, so a server that caps pages lower could repeat one indefinitely and be read as the end of the list — the silent truncation this tool exists to prevent. Exhaustion is now only the cursor's own boundary row echoed back alone; page size plays no part in the decision. - An empty `userIds` now means "the conversation containing only me" in both match modes. `includes` checks every requested id is present, which is vacuously true for an empty list, so it previously returned the whole workspace — contradicting the documented contract. - Only resolve the session user when the query actually needs it. An `includes` query never uses it, so it was an extra round trip per scan. - Hoist the expected-participant array out of the exact matcher instead of rebuilding it for every size-matching row. - Update the list-conversations usage guidelines in mcp-server.ts, per AGENTS.md. They still described a plain listing tool, so an LLM had no way to know it can now find a conversation by participants — the feature is unusable if the guidance never mentions it. Co-Authored-By: Claude Opus 5 (1M context) --- src/mcp-server.ts | 2 +- src/tools/list-conversations.test.ts | 76 ++++++++++++++++++++++++++-- src/tools/list-conversations.ts | 62 ++++++++++++++--------- 3 files changed, 109 insertions(+), 31 deletions(-) diff --git a/src/mcp-server.ts b/src/mcp-server.ts index b16a93d..38f070f 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -38,7 +38,7 @@ You have access to comprehensive Comms management tools for team communication a - **fetch-inbox**: Use to fetch inbox threads for a workspace, along with unread conversations and counts. Supports archiveFilter values of active, archived, or all; use all when the user needs both open and done threads. Optionally set onlyUnread to focus on unread items. - **list-channels**: Use to discover channels in a workspace. Requires a workspace ID. Optionally set includeArchived to true to also list archived channels. Returns channel names, IDs, descriptions, visibility, archive status, and URLs. -- **list-conversations**: Use to discover conversations (direct messages) in a workspace. Requires a workspace ID. Optionally set includeArchived to true to also list archived conversations. Returns conversation IDs, titles, a partial list of participant user IDs and names, archive status, last-active timestamps, snippets, and URLs. +- **list-conversations**: Use to discover conversations (direct messages) in a workspace, or to find one by who is in it. Requires a workspace ID. To find a specific conversation, pass userIds (excluding yourself) — by default that returns the single conversation whose participants are exactly you plus those users, which is how you locate a particular direct or group DM; an empty array finds the conversation containing only you. Set matchMode to "includes" instead to list every conversation those users appear in. Prefer this over listing and scanning yourself: filtered lookups page through the whole workspace internally and answer in one call. Without userIds, returns a page of conversations — pass the returned cursor to fetch the next page, and limit to size it. Optionally set includeArchived to true to also list archived conversations. Returns conversation IDs, titles, a partial list of participant user IDs and names, archive status, last-active timestamps, snippets, and URLs. To send a message you do not need this tool: create-conversation resolves the right conversation from its recipients on its own. - **get-groups**: Use to discover group IDs in a workspace before notifying groups from tools that support group notifications. Requires a workspace ID. Optionally filter by group IDs or search text. Returns group IDs, names, and member counts (descriptions are never returned). Set includeMembers to true to also get each member's user ID, name, and email — use that to answer "who is in this group?" or to expand a group into individual recipients; leave it off when you only need IDs, since member lists make the response much longer. - **create-channel**: Use to create a channel in a workspace. Pass workspaceId, name, and optional description or public; omitting public creates a private channel. Pass numeric userIds to add initial members. - **update-channel**: Use to rename a channel or update its description/visibility. Pass channelId and at least one of name, description, or public. Pass description: null to clear the description. diff --git a/src/tools/list-conversations.test.ts b/src/tools/list-conversations.test.ts index 546971c..07a64e9 100644 --- a/src/tools/list-conversations.test.ts +++ b/src/tools/list-conversations.test.ts @@ -686,11 +686,13 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { }) it('should return every conversation containing the participants in includes mode', async () => { - mockCommsApi.conversations.getConversations.mockResolvedValue([ - createMockConversation({ id: 'conv-bob', userIds: [alice, bob] }), - createMockConversation({ id: 'conv-group', userIds: [alice, bob, carol] }), - createMockConversation({ id: 'conv-carol', userIds: [alice, carol] }), - ]) + mockCommsApi.conversations.getConversations + .mockResolvedValueOnce([ + createMockConversation({ id: 'conv-bob', userIds: [alice, bob] }), + createMockConversation({ id: 'conv-group', userIds: [alice, bob, carol] }), + createMockConversation({ id: 'conv-carol', userIds: [alice, carol] }), + ]) + .mockResolvedValueOnce([]) const result = await listConversations.execute( { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [bob], matchMode: 'includes' }, @@ -748,6 +750,70 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { expect(extractStructuredContent(result).totalConversations).toBe(0) }) + it('should throw when a server-capped page repeats, not report no match', async () => { + // The server may cap pages well below SCAN_PAGE_SIZE. If a capped page + // comes back unchanged the cursor is stuck, and returning "no match" would + // be indistinguishable from the conversation genuinely not existing. + mockCommsApi.conversations.getConversations.mockResolvedValue([ + createMockConversation({ id: 'conv-a', userIds: [alice, carol] }), + createMockConversation({ id: 'conv-b', userIds: [alice, 90001] }), + ]) + + await expect( + listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [bob] }, + mockCommsApi, + ), + ).rejects.toThrow('cursor is not advancing') + }) + + it('should treat a lone repeated boundary row as the end of the list', async () => { + // Some servers echo the cursor's own row back as the final page. That is + // exhaustion, not a stall, and must not throw. + const only = createMockConversation({ id: 'conv-only', userIds: [alice, carol] }) + mockCommsApi.conversations.getConversations.mockResolvedValue([only]) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [bob] }, + mockCommsApi, + ) + + expect(extractStructuredContent(result).totalConversations).toBe(0) + }) + + it('should not match every conversation for an empty userIds in includes mode', async () => { + // `every` over an empty array is vacuously true, so an unguarded includes + // matcher would return the whole workspace here. + mockCommsApi.conversations.getConversations.mockResolvedValue([ + createMockConversation({ id: 'conv-bob', userIds: [alice, bob] }), + createMockConversation({ id: 'conv-self', userIds: [alice] }), + ]) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [], matchMode: 'includes' }, + mockCommsApi, + ) + + const structuredContent = extractStructuredContent(result) + expect(structuredContent.totalConversations).toBe(1) + expect(structuredContent.conversations[0]).toMatchObject({ id: 'conv-self' }) + }) + + it('should not resolve the session user for an includes query that does not need it', async () => { + mockCommsApi.conversations.getConversations + .mockResolvedValueOnce([ + createMockConversation({ id: 'conv-bob', userIds: [alice, bob] }), + ]) + .mockResolvedValueOnce([]) + + await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, userIds: [bob], matchMode: 'includes' }, + mockCommsApi, + ) + + expect(mockCommsApi.users.getSessionUser).not.toHaveBeenCalled() + }) + it('should throw rather than truncate when the cursor stops advancing', async () => { // A full page of already-seen rows means the cursor is stuck. Returning // what we have would look identical to "no such conversation". diff --git a/src/tools/list-conversations.ts b/src/tools/list-conversations.ts index f992c8b..9cfcf68 100644 --- a/src/tools/list-conversations.ts +++ b/src/tools/list-conversations.ts @@ -21,7 +21,7 @@ const ArgsSchema = { .array(z.number()) .optional() .describe( - 'Filter to conversations with these participants, excluding yourself (you are always implied). An empty array matches the conversation containing only you. Omit to list without filtering. Use get-users to resolve names to IDs.', + 'Filter to conversations with these participants, excluding yourself (you are always implied). An empty array matches the conversation containing only you, whatever matchMode says. Omit to list without filtering. Use get-users to resolve names to IDs.', ), matchMode: z .enum(MATCH_MODES) @@ -168,12 +168,16 @@ async function* iterateConversations( const unseen = page.filter((conversation) => !seenIds.has(conversation.id)) if (unseen.length === 0) { - // Nothing new. On a full page that means the cursor has stopped - // advancing, and truncating silently is how conversations "disappear"; - // on a short one it just means the boundary row repeated at the end. - if (page.length >= SCAN_PAGE_SIZE) { + // Nothing new. That is the end of the list only when the page is the + // cursor's own boundary row coming back on its own. Any larger page of + // already-seen rows means the cursor has stopped advancing — including a + // server-capped page that keeps repeating — and returning there would + // silently truncate, which is how conversations "disappear". Page size + // deliberately plays no part in this: the server may cap it anywhere. + const isBoundaryRepeat = page.length === 1 && cursor?.beforeId === page[0]?.id + if (!isBoundaryRepeat) { throw new Error( - `conversations/get returned a full page with no new conversations (workspace ${workspaceId}); results would be incomplete.`, + `conversations/get returned a page with no new conversations (workspace ${workspaceId}); the cursor is not advancing and results would be incomplete.`, ) } return @@ -189,30 +193,31 @@ async function* iterateConversations( } } -/** - * Build the participant predicate once per query rather than per conversation — - * `exact` needs a set to compare against, and rebuilding it for every row scanned - * is wasted work on a workspace-wide walk. - */ -function buildParticipantMatcher( +// Both matchers are built once per query rather than per conversation: the sets +// they compare against are fixed for the whole walk, and rebuilding them for every +// row scanned is wasted work on a workspace-wide scan. + +/** Participants must include all of `targetIds`, and may include anyone else. */ +function buildIncludesMatcher( targetIds: readonly number[], - sessionUserId: number, - matchMode: (typeof MATCH_MODES)[number], ): (conversation: Conversation) => boolean { - if (matchMode === 'includes') { - return (conversation) => { - const participants = new Set(conversation.userIds) - return targetIds.every((id) => participants.has(id)) - } + return (conversation) => { + const participants = new Set(conversation.userIds) + return targetIds.every((id) => participants.has(id)) } +} - // Exact: the participant set is you plus the requested users, no one else. - // An empty `targetIds` therefore matches the conversation containing only you. +/** Participants are exactly you plus `targetIds`, and no one else. */ +function buildExactMatcher( + targetIds: readonly number[], + sessionUserId: number, +): (conversation: Conversation) => boolean { const expected = new Set([...targetIds, sessionUserId]) + const expectedIds = [...expected] return (conversation) => { const participants = new Set(conversation.userIds) return ( - participants.size === expected.size && [...expected].every((id) => participants.has(id)) + participants.size === expected.size && expectedIds.every((id) => participants.has(id)) ) } } @@ -263,9 +268,16 @@ async function scanForParticipants( limit: number, cursor: PageCursor | undefined, ): Promise { - const sessionUser = await client.users.getSessionUser() + // An empty `targetIds` means "the conversation containing only me", which is an + // exact participant set whatever the mode says — `includes` with nothing to + // check is vacuously true and would otherwise match every conversation. + // Resolving the session user is only needed on that path, so it stays lazy. + const isExactQuery = matchMode === 'exact' || targetIds.length === 0 + const matchesQuery = isExactQuery + ? buildExactMatcher(targetIds, (await client.users.getSessionUser()).id) + : buildIncludesMatcher(targetIds) + const matches: Conversation[] = [] - const matchesQuery = buildParticipantMatcher(targetIds, sessionUser.id, matchMode) for await (const conversation of iterateConversations( client, @@ -279,7 +291,7 @@ async function scanForParticipants( // An exact participant set identifies at most one conversation — the same // rule the backend dedupes on — so the first hit is the answer. - if (matchMode === 'exact') { + if (isExactQuery) { return { conversations: matches, hasMore: false } } From 269aed8b6413deab7ebfbed9331747482e20277e Mon Sep 17 00:00:00 2001 From: Rain S <5832662+rainsjm@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:38:28 +0100 Subject: [PATCH 4/4] fix(list-conversations): never infer exhaustion from a short page The caller-driven path reported hasMore false whenever a page came back shorter than the requested limit, which assumes the server always honours `limit`. A page cap that is dynamic, or sized by payload rather than row count, breaks that assumption without any visible symptom: the caller stops early believing it has the complete list. That is the exact failure this tool was changed to eliminate, and the internal scan already refuses to make the same assumption. A non-empty page now always carries a continuation cursor, and only an empty page ends the listing. The cost is one extra request per full listing. The text output says "More results may be available" rather than asserting what a single page cannot know. Earlier reasoning for keeping the inference rested on one probe of a live workspace returning 237 rows to a single request. That rules out a fixed row-count cap below 100, but not a dynamic or size-based one, so it was never enough to justify the carve-out. Co-Authored-By: Claude Opus 5 (1M context) --- src/mcp-server.ts | 2 +- src/tools/list-conversations.test.ts | 26 ++++++++++++++++++++++---- src/tools/list-conversations.ts | 26 +++++++++++++++++--------- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 38f070f..9476183 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -38,7 +38,7 @@ You have access to comprehensive Comms management tools for team communication a - **fetch-inbox**: Use to fetch inbox threads for a workspace, along with unread conversations and counts. Supports archiveFilter values of active, archived, or all; use all when the user needs both open and done threads. Optionally set onlyUnread to focus on unread items. - **list-channels**: Use to discover channels in a workspace. Requires a workspace ID. Optionally set includeArchived to true to also list archived channels. Returns channel names, IDs, descriptions, visibility, archive status, and URLs. -- **list-conversations**: Use to discover conversations (direct messages) in a workspace, or to find one by who is in it. Requires a workspace ID. To find a specific conversation, pass userIds (excluding yourself) — by default that returns the single conversation whose participants are exactly you plus those users, which is how you locate a particular direct or group DM; an empty array finds the conversation containing only you. Set matchMode to "includes" instead to list every conversation those users appear in. Prefer this over listing and scanning yourself: filtered lookups page through the whole workspace internally and answer in one call. Without userIds, returns a page of conversations — pass the returned cursor to fetch the next page, and limit to size it. Optionally set includeArchived to true to also list archived conversations. Returns conversation IDs, titles, a partial list of participant user IDs and names, archive status, last-active timestamps, snippets, and URLs. To send a message you do not need this tool: create-conversation resolves the right conversation from its recipients on its own. +- **list-conversations**: Use to discover conversations (direct messages) in a workspace, or to find one by who is in it. Requires a workspace ID. To find a specific conversation, pass userIds (excluding yourself) — by default that returns the single conversation whose participants are exactly you plus those users, which is how you locate a particular direct or group DM; an empty array finds the conversation containing only you. Set matchMode to "includes" instead to list every conversation those users appear in. Prefer this over listing and scanning yourself: filtered lookups page through the whole workspace internally and answer in one call. Without userIds, returns a page of conversations — pass the returned cursor to fetch the next page, and limit to size it. Keep fetching until a page comes back empty; a short page does not mean the end of the list. Optionally set includeArchived to true to also list archived conversations. Returns conversation IDs, titles, a partial list of participant user IDs and names, archive status, last-active timestamps, snippets, and URLs. To send a message you do not need this tool: create-conversation resolves the right conversation from its recipients on its own. - **get-groups**: Use to discover group IDs in a workspace before notifying groups from tools that support group notifications. Requires a workspace ID. Optionally filter by group IDs or search text. Returns group IDs, names, and member counts (descriptions are never returned). Set includeMembers to true to also get each member's user ID, name, and email — use that to answer "who is in this group?" or to expand a group into individual recipients; leave it off when you only need IDs, since member lists make the response much longer. - **create-channel**: Use to create a channel in a workspace. Pass workspaceId, name, and optional description or public; omitting public creates a private channel. Pass numeric userIds to add initial members. - **update-channel**: Use to rename a channel or update its description/visibility. Pass channelId and at least one of name, description, or public. Pass description: null to clear the description. diff --git a/src/tools/list-conversations.test.ts b/src/tools/list-conversations.test.ts index 07a64e9..c4e292a 100644 --- a/src/tools/list-conversations.test.ts +++ b/src/tools/list-conversations.test.ts @@ -81,7 +81,9 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { type: 'list_conversations', workspaceId: TEST_IDS.WORKSPACE_1, totalConversations: 2, - hasMore: false, + // A non-empty page always carries a cursor; only an empty one ends it. + hasMore: true, + cursor: expect.any(String), conversations: expect.arrayContaining([ expect.objectContaining({ id: TEST_IDS.CONVERSATION_1, @@ -482,10 +484,13 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { expect(structuredContent.cursor).toEqual(expect.any(String)) const textContent = extractTextContent(result) - expect(textContent).toContain('More results available.') + expect(textContent).toContain('More results may be available.') }) - it('should not report hasMore when the page is short', async () => { + it('should still offer a cursor when the page is shorter than the limit', async () => { + // A short page is not proof of exhaustion — the server may cap a page + // below the requested limit. Treating it as the end would leave later + // conversations unreachable. mockCommsApi.conversations.getConversations.mockResolvedValue([ createMockConversation(), ]) @@ -498,10 +503,23 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { mockCommsApi, ) + const structuredContent = extractStructuredContent(result) + expect(structuredContent.hasMore).toBe(true) + expect(structuredContent.cursor).toEqual(expect.any(String)) + expect(extractTextContent(result)).toContain('More results may be available.') + }) + + it('should report exhaustion only when a page comes back empty', async () => { + mockCommsApi.conversations.getConversations.mockResolvedValue([]) + + const result = await listConversations.execute( + { workspaceId: TEST_IDS.WORKSPACE_1, limit: 50 }, + mockCommsApi, + ) + const structuredContent = extractStructuredContent(result) expect(structuredContent.hasMore).toBe(false) expect(structuredContent).not.toHaveProperty('cursor') - expect(extractTextContent(result)).not.toContain('More results available.') }) it('should resume from a returned cursor via the compound (lastActive, id) key', async () => { diff --git a/src/tools/list-conversations.ts b/src/tools/list-conversations.ts index 9cfcf68..9615b4d 100644 --- a/src/tools/list-conversations.ts +++ b/src/tools/list-conversations.ts @@ -38,7 +38,12 @@ const ArgsSchema = { .optional() .default(50) .describe('Maximum number of conversations to return.'), - cursor: z.string().optional().describe('Cursor for pagination.'), + cursor: z + .string() + .optional() + .describe( + 'Cursor for pagination. A non-empty page always returns one, since the page itself cannot tell you whether more follow; keep fetching until a page comes back empty.', + ), } type ConversationData = { @@ -240,17 +245,18 @@ async function fetchConversationPage( buildPageArgs(workspaceId, includeArchived, limit, cursor), ) - // A short page means the list is exhausted; a full one means there may be more. - // This assumes the server honours `limit` — if it caps the page lower, a caller - // driving pagination stops early. The internal scan deliberately does not rely - // on that assumption; see iterateConversations. - const hasMore = conversations.length >= limit + // Exhaustion is an empty page, never a short one. Reading a short page as the + // end assumes the server honours `limit`, and a page cap that is dynamic or + // sized by payload rather than row count would break that assumption silently — + // leaving conversations no caller could reach, which is the bug this tool + // exists to fix. The cost of not assuming is one extra request per listing. const last = conversations[conversations.length - 1] + const hasMore = conversations.length > 0 return { conversations, hasMore, - ...(hasMore && last ? { nextCursor: encodeCursor(last) } : {}), + ...(last ? { nextCursor: encodeCursor(last) } : {}), } } @@ -429,7 +435,9 @@ async function generateConversationsList( if (hasMore) { lines.push('## Next Steps') lines.push('') - lines.push('More results available. Use the cursor to fetch the next page.') + // "may be" rather than "are": a non-empty page cannot tell us whether + // anything follows it, and the tool should not assert what it cannot know. + lines.push('More results may be available. Use the cursor to fetch the next page.') } const textContent = lines.join('\n') @@ -465,7 +473,7 @@ async function generateConversationsList( const listConversations = { name: ToolNames.LIST_CONVERSATIONS, description: - 'List conversations (direct messages) in a workspace, or find a specific one by its participants. Pass userIds to get the conversation with exactly those people (plus you) — an empty array finds the conversation with only you — or set matchMode to "includes" for every conversation containing them. Without userIds, returns a page of conversations; use the returned cursor for the next page. By default only active conversations are returned; set includeArchived to true to also include archived ones. Returns conversation IDs, titles, the full list of participant user IDs (with names resolved for the first few), archive status, last-active timestamps, snippets, and URLs.', + 'List conversations (direct messages) in a workspace, or find a specific one by its participants. Pass userIds to get the conversation with exactly those people (plus you) — an empty array finds the conversation with only you — or set matchMode to "includes" for every conversation containing them. Without userIds, returns a page of conversations; use the returned cursor for the next page, and keep going until a page comes back empty rather than stopping at a short one. By default only active conversations are returned; set includeArchived to true to also include archived ones. Returns conversation IDs, titles, the full list of participant user IDs (with names resolved for the first few), archive status, last-active timestamps, snippets, and URLs.', parameters: ArgsSchema, outputSchema: ListConversationsOutputSchema.shape, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },