Skip to content

Commit 6c88811

Browse files
Expose paged channel action results (#88993)
* fix(discord): expose paged thread list results * fix(discord): keep thread pagination local * fix(discord): use archive timestamps for thread cursors * test(discord): cover thread pagination results --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 4a66667 commit 6c88811

2 files changed

Lines changed: 212 additions & 1 deletion

File tree

extensions/discord/src/actions/runtime.messaging.send.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,75 @@ function hasDiscordComponentObjectKeys(value: unknown): value is Record<string,
2424
);
2525
}
2626

27+
function readDiscordThreadArchiveTimestamp(thread: unknown): string | undefined {
28+
if (!thread || typeof thread !== "object" || Array.isArray(thread)) {
29+
return undefined;
30+
}
31+
const record = thread as Record<string, unknown>;
32+
const metadata = record.thread_metadata;
33+
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
34+
const archiveTimestamp = (metadata as Record<string, unknown>).archive_timestamp;
35+
if (typeof archiveTimestamp === "string" && archiveTimestamp.trim()) {
36+
return archiveTimestamp;
37+
}
38+
}
39+
return undefined;
40+
}
41+
42+
type DiscordThreadListActionResult = {
43+
ok: true;
44+
threads: unknown;
45+
complete: boolean;
46+
hasMore: boolean;
47+
returnedCount: number;
48+
source: "discord.threadList.archived" | "discord.threadList.active";
49+
query: {
50+
guildId: string;
51+
channelId?: string;
52+
includeArchived: boolean;
53+
before?: string;
54+
limit?: number;
55+
};
56+
nextBefore?: string;
57+
};
58+
59+
function normalizeDiscordThreadListActionResult(params: {
60+
value: unknown;
61+
includeArchived: boolean;
62+
channelId?: string;
63+
guildId: string;
64+
limit?: number;
65+
before?: string;
66+
}): DiscordThreadListActionResult {
67+
const record =
68+
params.value && typeof params.value === "object" && !Array.isArray(params.value)
69+
? (params.value as Record<string, unknown>)
70+
: undefined;
71+
const threadItems = Array.isArray(record?.threads) ? record.threads : [];
72+
const hasMore = record?.has_more === true;
73+
const nextBefore =
74+
params.includeArchived && hasMore
75+
? readDiscordThreadArchiveTimestamp(threadItems[threadItems.length - 1])
76+
: undefined;
77+
78+
return {
79+
ok: true,
80+
threads: params.value,
81+
complete: !hasMore,
82+
hasMore,
83+
returnedCount: threadItems.length,
84+
source: params.includeArchived ? "discord.threadList.archived" : "discord.threadList.active",
85+
query: {
86+
guildId: params.guildId,
87+
...(params.channelId ? { channelId: params.channelId } : {}),
88+
includeArchived: params.includeArchived,
89+
...(params.before ? { before: params.before } : {}),
90+
...(params.limit !== undefined ? { limit: params.limit } : {}),
91+
},
92+
...(nextBefore ? { nextBefore } : {}),
93+
};
94+
}
95+
2796
async function appendDiscordThreadRenameResult(
2897
ctx: DiscordMessagingActionContext,
2998
params: {
@@ -306,7 +375,16 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
306375
},
307376
ctx.withOpts(),
308377
);
309-
return jsonResult({ ok: true, threads });
378+
return jsonResult(
379+
normalizeDiscordThreadListActionResult({
380+
value: threads,
381+
guildId,
382+
channelId,
383+
includeArchived: includeArchived === true,
384+
before,
385+
limit,
386+
}),
387+
);
310388
}
311389
case "threadReply": {
312390
if (!ctx.isActionEnabled("threads")) {

extensions/discord/src/actions/runtime.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ const {
101101
kickMemberDiscord,
102102
listGuildChannelsDiscord,
103103
listPinsDiscord,
104+
listThreadsDiscord,
104105
moveChannelDiscord,
105106
reactMessageDiscord,
106107
readMessagesDiscord,
@@ -271,6 +272,138 @@ describe("handleDiscordMessagingAction", () => {
271272
]);
272273
});
273274

275+
it("surfaces incomplete archived thread pages at the action boundary", async () => {
276+
listThreadsDiscord.mockResolvedValueOnce({
277+
threads: [
278+
{
279+
id: "thread-1",
280+
name: "Old project",
281+
thread_metadata: {
282+
archive_timestamp: "2026-05-25T17:00:00.000Z",
283+
},
284+
},
285+
],
286+
members: [],
287+
has_more: true,
288+
});
289+
290+
const result = await handleMessagingAction(
291+
"threadList",
292+
{
293+
guildId: "G1",
294+
channelId: "C1",
295+
includeArchived: true,
296+
before: "2026-05-26T17:00:00.000Z",
297+
limit: 1,
298+
},
299+
enableAllActions,
300+
);
301+
302+
expect(mockCall(listThreadsDiscord, "listThreadsDiscord")).toEqual([
303+
{
304+
guildId: "G1",
305+
channelId: "C1",
306+
includeArchived: true,
307+
before: "2026-05-26T17:00:00.000Z",
308+
limit: 1,
309+
},
310+
{ cfg: DISCORD_TEST_CFG },
311+
]);
312+
expect(result.details).toMatchObject({
313+
ok: true,
314+
complete: false,
315+
hasMore: true,
316+
returnedCount: 1,
317+
source: "discord.threadList.archived",
318+
nextBefore: "2026-05-25T17:00:00.000Z",
319+
query: {
320+
guildId: "G1",
321+
channelId: "C1",
322+
includeArchived: true,
323+
before: "2026-05-26T17:00:00.000Z",
324+
limit: 1,
325+
},
326+
});
327+
expect((result.details as { threads?: unknown }).threads).toEqual({
328+
threads: [
329+
{
330+
id: "thread-1",
331+
name: "Old project",
332+
thread_metadata: {
333+
archive_timestamp: "2026-05-25T17:00:00.000Z",
334+
},
335+
},
336+
],
337+
members: [],
338+
has_more: true,
339+
});
340+
});
341+
342+
it("omits archived thread pagination cursors when Discord omits archive timestamps", async () => {
343+
listThreadsDiscord.mockResolvedValueOnce({
344+
threads: [
345+
{
346+
id: "thread-without-archive-timestamp",
347+
name: "Legacy project",
348+
},
349+
],
350+
members: [],
351+
has_more: true,
352+
});
353+
354+
const result = await handleMessagingAction(
355+
"threadList",
356+
{
357+
guildId: "G1",
358+
channelId: "C1",
359+
includeArchived: true,
360+
limit: 1,
361+
},
362+
enableAllActions,
363+
);
364+
365+
expect(result.details).toMatchObject({
366+
ok: true,
367+
complete: false,
368+
hasMore: true,
369+
returnedCount: 1,
370+
source: "discord.threadList.archived",
371+
});
372+
expect(result.details).not.toHaveProperty("nextBefore");
373+
});
374+
375+
it("marks active thread results complete when Discord returns no pagination state", async () => {
376+
listThreadsDiscord.mockResolvedValueOnce({
377+
threads: [{ id: "thread-active", name: "Current project" }],
378+
members: [{ id: "member-1" }],
379+
});
380+
381+
const result = await handleMessagingAction(
382+
"threadList",
383+
{
384+
guildId: "G1",
385+
},
386+
enableAllActions,
387+
);
388+
389+
expect(result.details).toMatchObject({
390+
ok: true,
391+
complete: true,
392+
hasMore: false,
393+
returnedCount: 1,
394+
source: "discord.threadList.active",
395+
query: {
396+
guildId: "G1",
397+
includeArchived: false,
398+
},
399+
});
400+
expect((result.details as { threads?: unknown }).threads).toEqual({
401+
threads: [{ id: "thread-active", name: "Current project" }],
402+
members: [{ id: "member-1" }],
403+
});
404+
expect(result.details).not.toHaveProperty("nextBefore");
405+
});
406+
274407
it("resolves Discord DM targets for reaction adds", async () => {
275408
const resolveReactionTarget = vi.fn(async () => "DM1");
276409
discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget;

0 commit comments

Comments
 (0)