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" diff --git a/src/mcp-server.ts b/src/mcp-server.ts index b16a93d..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. 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. 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 e6a57c6..c4e292a 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,9 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { type: 'list_conversations', workspaceId: TEST_IDS.WORKSPACE_1, totalConversations: 2, + // 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, @@ -110,6 +119,7 @@ describe(`${LIST_CONVERSATIONS} tool`, () => { workspaceId: TEST_IDS.WORKSPACE_1, conversations: [], totalConversations: 0, + hasMore: false, }) }) @@ -386,7 +396,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 +406,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 +427,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 +440,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 +460,399 @@ 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 may be available.') + }) + + 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(), + ]) + 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(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') + }) + + 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 + .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' }, + 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 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". + 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..9615b4d 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,33 @@ 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, whatever matchMode says. 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. 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 = { @@ -34,6 +63,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 +78,243 @@ 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. 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 page with no new conversations (workspace ${workspaceId}); the cursor is not advancing and 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 } + } +} + +// 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[], +): (conversation: Conversation) => boolean { + return (conversation) => { + const participants = new Set(conversation.userIds) + return targetIds.every((id) => participants.has(id)) + } +} + +/** 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 && expectedIds.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), + ) + + // 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, + ...(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 { + // 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[] = [] + + 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 (isExactQuery) { + 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 +356,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 +432,14 @@ async function generateConversationsList( lines.push('') } + if (hasMore) { + lines.push('## Next Steps') + lines.push('') + // "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') const structuredContent: ListConversationsStructured = { @@ -195,6 +463,8 @@ async function generateConversationsList( } }), totalConversations: conversations.length, + hasMore, + ...(nextCursor && { cursor: nextCursor }), } return { textContent, structuredContent } @@ -203,13 +473,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, 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 }, 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(), }) /**