diff --git a/src/discord/monitor/threading.test.ts b/src/discord/monitor/threading.test.ts index 2b59bc4536..d00c7f416c 100644 --- a/src/discord/monitor/threading.test.ts +++ b/src/discord/monitor/threading.test.ts @@ -2,6 +2,7 @@ import type { Client } from "@buape/carbon"; import { describe, expect, it } from "vitest"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { + maybeCreateDiscordAutoThread, resolveDiscordAutoThreadContext, resolveDiscordAutoThreadReplyPlan, resolveDiscordReplyDeliveryPlan, @@ -112,6 +113,73 @@ describe("resolveDiscordReplyDeliveryPlan", () => { }); }); +describe("maybeCreateDiscordAutoThread", () => { + it("returns existing thread ID when creation fails due to race condition", async () => { + // First call succeeds (simulating another agent creating the thread) + let callCount = 0; + const client = { + rest: { + post: async () => { + callCount++; + throw new Error("A thread has already been created on this message"); + }, + get: async () => { + // Return message with existing thread (simulating race condition resolution) + return { thread: { id: "existing-thread" } }; + }, + }, + } as unknown as Client; + + const result = await maybeCreateDiscordAutoThread({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + }); + + expect(result).toBe("existing-thread"); + }); + + it("returns undefined when creation fails and no existing thread found", async () => { + const client = { + rest: { + post: async () => { + throw new Error("Some other error"); + }, + get: async () => { + // Message has no thread + return { thread: null }; + }, + }, + } as unknown as Client; + + const result = await maybeCreateDiscordAutoThread({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + }); + + expect(result).toBeUndefined(); + }); +}); + describe("resolveDiscordAutoThreadReplyPlan", () => { it("switches delivery + session context to the created thread", async () => { const client = { diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 962e7cd76b..470962aaf8 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -358,8 +358,24 @@ export async function maybeCreateDiscordAutoThread(params: { return createdId || undefined; } catch (err) { logVerbose( - `discord: autoThread failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`, + `discord: autoThread creation failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`, ); + // Race condition: another agent may have already created a thread on this + // message. Re-fetch the message to check for an existing thread. + try { + const msg = (await params.client.rest.get( + Routes.channelMessage(params.message.channelId, params.message.id), + )) as { thread?: { id?: string } }; + const existingThreadId = msg?.thread?.id ? String(msg.thread.id) : ""; + if (existingThreadId) { + logVerbose( + `discord: autoThread reusing existing thread ${existingThreadId} on ${params.message.channelId}/${params.message.id}`, + ); + return existingThreadId; + } + } catch { + // If the refetch also fails, fall through to return undefined. + } return undefined; } }