From af5bf393ae7e1110a1a63bee5ff1cf682c11d80a Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Thu, 10 Sep 2026 13:17:29 -0700 Subject: [PATCH 1/2] fix(search): paginate channel fallback with cursors --- src/slack/search-messages.ts | 37 +++++++----------- test/search-command.test.ts | 73 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/src/slack/search-messages.ts b/src/slack/search-messages.ts index cc8d9ef..539f5f9 100644 --- a/src/slack/search-messages.ts +++ b/src/slack/search-messages.ts @@ -174,19 +174,25 @@ export async function searchMessagesInChannelsFallback( const results: SearchCompactMessage[] = []; - for (const channelId of channelIds) { - let cursorLatest: string | undefined; + channelLoop: for (const channelId of channelIds) { + let cursor: string | undefined; for (;;) { const resp = await client.api("conversations.history", { channel: channelId, limit: 200, - latest: cursorLatest, + cursor, }); const messages = isRecord(resp) ? asArray(resp.messages).filter(isRecord) : []; if (messages.length === 0) { break; } + const responseMetadata = isRecord(resp.response_metadata) ? resp.response_metadata : null; + const nextCursor = responseMetadata + ? getString(responseMetadata.next_cursor)?.trim() || undefined + : undefined; + + let reachedAfterBoundary = false; for (const m of messages) { const summary = messageSummaryFromApiMessage(channelId, m); @@ -196,7 +202,7 @@ export async function searchMessagesInChannelsFallback( continue; } if (afterSec !== null && tsNum < afterSec) { - cursorLatest = undefined; + reachedAfterBoundary = true; break; } } @@ -230,31 +236,14 @@ export async function searchMessagesInChannelsFallback( matchedSummaries.push(summary); results.push(toSearchCompactMessage(compact)); if (results.length >= input.limit) { - const referencedUserIds = collectReferencedUserIds(matchedSummaries, { - includeReactions: false, - }); - const usersById = await resolveUsersById({ - client, - workspaceUrl: input.workspace_url ?? "", - userIds: referencedUserIds, - forceRefresh: Boolean(input.refreshUsers), - }); - return { - messages: results, - referenced_users: toReferencedUsers(referencedUserIds, usersById), - }; + break channelLoop; } } - if (!cursorLatest) { - break; - } - - const last = messages.at(-1); - cursorLatest = last ? getString(last.ts) : undefined; - if (!cursorLatest) { + if (reachedAfterBoundary || !nextCursor || nextCursor === cursor) { break; } + cursor = nextCursor; } } diff --git a/test/search-command.test.ts b/test/search-command.test.ts index f918ce9..3008c3c 100644 --- a/test/search-command.test.ts +++ b/test/search-command.test.ts @@ -49,6 +49,7 @@ function createClient(calls: ApiCall[]) { if (method === "conversations.history") { return { + response_metadata: { next_cursor: "" }, messages: [ { ts: "1.000001", @@ -154,6 +155,78 @@ describe("search referenced users", () => { expect(result.referenced_users).not.toHaveProperty("U44444444"); }); + test("searchSlack scans older channel history pages using Slack cursors", async () => { + const calls: ApiCall[] = []; + const client = { + api: async (method: string, params: Record) => { + calls.push({ method, params }); + if (method !== "conversations.history") { + throw new Error(`Unexpected API method: ${method}`); + } + if (params.cursor === undefined) { + return { + response_metadata: { next_cursor: "page-2" }, + messages: [{ ts: "2.000002", text: "not a match", user: "U11111111" }], + }; + } + if (params.cursor === "page-2") { + return { + response_metadata: { next_cursor: "" }, + messages: [{ ts: "1.000001", text: "needle", user: "U22222222" }], + }; + } + throw new Error(`Unexpected cursor: ${String(params.cursor)}`); + }, + }; + + const result = await searchSlack({ + client: client as never, + auth: { auth_type: "standard", token: "x" }, + options: { + workspace_url: "https://workspace.slack.com", + query: "needle", + kind: "messages", + channels: ["C12345678"], + limit: 20, + max_content_chars: 4000, + content_type: "any", + download: false, + }, + }); + + expect(result.messages).toHaveLength(1); + expect(result.messages?.[0]?.content).toBe("needle"); + expect( + calls + .filter((call) => call.method === "conversations.history") + .map((call) => call.params.cursor), + ).toEqual([undefined, "page-2"]); + }); + + test("searchSlack does not resolve users at the result limit without opt-in", async () => { + const calls: ApiCall[] = []; + const client = createClient(calls) as never; + + const result = await searchSlack({ + client, + auth: { auth_type: "standard", token: "x" }, + options: { + workspace_url: "https://workspace.slack.com", + query: "hello", + kind: "messages", + channels: ["C12345678"], + limit: 1, + max_content_chars: 4000, + content_type: "any", + download: false, + }, + }); + + expect(result.messages).toHaveLength(1); + expect(result.referenced_users).toBeUndefined(); + expect(calls.filter((call) => call.method === "users.info")).toHaveLength(0); + }); + test("search command forwards --refresh-users and bypasses cached user lookups", async () => { const calls: ApiCall[] = []; const ctx = createContext(calls); From 2d2100c039e48c811d2312821e2dfe1da09d16b9 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Fri, 11 Sep 2026 16:38:17 -0700 Subject: [PATCH 2/2] fix(search): follow cursors across empty pages --- src/slack/search-messages.ts | 8 ++- test/search-command.test.ts | 95 ++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/slack/search-messages.ts b/src/slack/search-messages.ts index 539f5f9..bed1b5d 100644 --- a/src/slack/search-messages.ts +++ b/src/slack/search-messages.ts @@ -176,6 +176,7 @@ export async function searchMessagesInChannelsFallback( channelLoop: for (const channelId of channelIds) { let cursor: string | undefined; + const seenCursors = new Set(); for (;;) { const resp = await client.api("conversations.history", { channel: channelId, @@ -183,10 +184,6 @@ export async function searchMessagesInChannelsFallback( cursor, }); const messages = isRecord(resp) ? asArray(resp.messages).filter(isRecord) : []; - if (messages.length === 0) { - break; - } - const responseMetadata = isRecord(resp.response_metadata) ? resp.response_metadata : null; const nextCursor = responseMetadata ? getString(responseMetadata.next_cursor)?.trim() || undefined @@ -240,9 +237,10 @@ export async function searchMessagesInChannelsFallback( } } - if (reachedAfterBoundary || !nextCursor || nextCursor === cursor) { + if (reachedAfterBoundary || !nextCursor || seenCursors.has(nextCursor)) { break; } + seenCursors.add(nextCursor); cursor = nextCursor; } } diff --git a/test/search-command.test.ts b/test/search-command.test.ts index 3008c3c..8a4b9ba 100644 --- a/test/search-command.test.ts +++ b/test/search-command.test.ts @@ -203,6 +203,101 @@ describe("search referenced users", () => { ).toEqual([undefined, "page-2"]); }); + test("searchSlack follows a valid cursor from an empty history page", async () => { + const calls: ApiCall[] = []; + const client = { + api: async (method: string, params: Record) => { + calls.push({ method, params }); + if (method !== "conversations.history") { + throw new Error(`Unexpected API method: ${method}`); + } + if (params.cursor === undefined) { + return { + response_metadata: { next_cursor: "page-2" }, + messages: [], + }; + } + if (params.cursor === "page-2") { + return { + response_metadata: { next_cursor: "" }, + messages: [{ ts: "1.000001", text: "needle", user: "U22222222" }], + }; + } + throw new Error(`Unexpected cursor: ${String(params.cursor)}`); + }, + }; + + const result = await searchSlack({ + client: client as never, + auth: { auth_type: "standard", token: "x" }, + options: { + workspace_url: "https://workspace.slack.com", + query: "needle", + kind: "messages", + channels: ["C12345678"], + limit: 20, + max_content_chars: 4000, + content_type: "any", + download: false, + }, + }); + + expect(result.messages).toHaveLength(1); + expect(result.messages?.[0]?.content).toBe("needle"); + expect( + calls + .filter((call) => call.method === "conversations.history") + .map((call) => call.params.cursor), + ).toEqual([undefined, "page-2"]); + }); + + test("searchSlack stops when a pagination cursor cycles", async () => { + const calls: ApiCall[] = []; + const client = { + api: async (method: string, params: Record) => { + calls.push({ method, params }); + if (method !== "conversations.history") { + throw new Error(`Unexpected API method: ${method}`); + } + if (calls.filter((call) => call.method === "conversations.history").length > 3) { + throw new Error("Repeated pagination cursor was requested"); + } + if (params.cursor === undefined) { + return { response_metadata: { next_cursor: "page-2" }, messages: [] }; + } + if (params.cursor === "page-2") { + return { response_metadata: { next_cursor: "page-3" }, messages: [] }; + } + if (params.cursor === "page-3") { + return { response_metadata: { next_cursor: "page-2" }, messages: [] }; + } + throw new Error(`Unexpected cursor: ${String(params.cursor)}`); + }, + }; + + const result = await searchSlack({ + client: client as never, + auth: { auth_type: "standard", token: "x" }, + options: { + workspace_url: "https://workspace.slack.com", + query: "needle", + kind: "messages", + channels: ["C12345678"], + limit: 20, + max_content_chars: 4000, + content_type: "any", + download: false, + }, + }); + + expect(result.messages).toHaveLength(0); + expect( + calls + .filter((call) => call.method === "conversations.history") + .map((call) => call.params.cursor), + ).toEqual([undefined, "page-2", "page-3"]); + }); + test("searchSlack does not resolve users at the result limit without opt-in", async () => { const calls: ApiCall[] = []; const client = createClient(calls) as never;