From e0a267afc60f80dc75dc8f3d6bb0afc9fa84003d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 11:00:48 +0100 Subject: [PATCH] fix: avoid repeated discord thread starter context --- CHANGELOG.md | 1 + .../src/monitor/message-handler.context.ts | 13 +++++- .../monitor/message-handler.process.test.ts | 45 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f90f939abd5..8aa1ee4c849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Discord: preserve attachment and sticker filenames when saving inbound media, so agents can see human-readable file names instead of only UUID-based paths. Fixes #59744. Thanks @xela92 and @rockcent. - Discord: preserve non-ASCII channel names in session display labels while keeping allowlist matching on the existing ASCII slug contract. Thanks @swjeong9. - Discord/PluralKit: canonicalize proxied webhook turns to the original Discord message id for inbound dedupe, while preserving the proxy message id for reply routing. Thanks @acgh213. +- Discord: only inject thread starter context on the first turn of the effective thread session, so follow-up thread replies do not repeat the starter block. Fixes #41355; supersedes #44447 and #44449. Thanks @p3nchan. - Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma. - Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq. - Codex/app-server: make startup retry cleanup ownership-aware so concurrent Codex lanes cannot close another lane's freshly restarted shared app-server client. Thanks @vincentkoc. diff --git a/extensions/discord/src/monitor/message-handler.context.ts b/extensions/discord/src/monitor/message-handler.context.ts index a83e9e39a03..e7bcdd95baa 100644 --- a/extensions/discord/src/monitor/message-handler.context.ts +++ b/extensions/discord/src/monitor/message-handler.context.ts @@ -296,6 +296,15 @@ export async function buildDiscordMessageProcessContext(params: { })) : undefined; const originatingTo = autoThreadContext?.OriginatingTo ?? dmConversationTarget ?? replyTarget; + const effectiveSessionKey = + boundSessionKey ?? autoThreadContext?.SessionKey ?? threadKeys.sessionKey; + const effectivePreviousTimestamp = + effectiveSessionKey === route.sessionKey + ? previousTimestamp + : readSessionUpdatedAt({ + storePath, + sessionKey: effectiveSessionKey, + }); const ctxPayload = finalizeInboundContext({ Body: combinedBody, @@ -306,7 +315,7 @@ export async function buildDiscordMessageProcessContext(params: { ...(preflightAudioTranscript !== undefined ? { Transcript: preflightAudioTranscript } : {}), From: effectiveFrom, To: effectiveTo, - SessionKey: boundSessionKey ?? autoThreadContext?.SessionKey ?? threadKeys.sessionKey, + SessionKey: effectiveSessionKey, AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : "channel", ConversationLabel: fromLabel, @@ -335,7 +344,7 @@ export async function buildDiscordMessageProcessContext(params: { ModelParentSessionKey: autoThreadContext?.ModelParentSessionKey ?? modelParentSessionKey ?? undefined, MessageThreadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined, - ThreadStarterBody: threadStarterBody, + ThreadStarterBody: !effectivePreviousTimestamp ? threadStarterBody : undefined, ThreadLabel: threadLabel, Timestamp: resolveTimestampMs(message.timestamp), ...mediaPayload, diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index ca23c83ea43..984a9dc4d9c 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -364,6 +364,7 @@ function getLastDispatchCtx(): OriginatingTo?: string; ParentSessionKey?: string; SessionKey?: string; + ThreadStarterBody?: string; To?: string; Transcript?: string; } @@ -384,6 +385,7 @@ function getLastDispatchCtx(): OriginatingTo?: string; ParentSessionKey?: string; SessionKey?: string; + ThreadStarterBody?: string; To?: string; Transcript?: string; }; @@ -1053,6 +1055,49 @@ describe("processDiscordMessage session routing", () => { }); expect(getLastDispatchCtx()?.ParentSessionKey).toBeUndefined(); }); + + it("omits thread starter context when the effective thread session already exists", async () => { + const threadSessionKey = "agent:main:discord:channel:thread-1"; + readSessionUpdatedAt.mockImplementation((params?: unknown) => { + const sessionKey = (params as { sessionKey?: string } | undefined)?.sessionKey; + return sessionKey === threadSessionKey ? 1_700_000_000_000 : undefined; + }); + const rest = { + get: vi.fn(async () => ({ + content: "original thread starter", + embeds: [], + author: { id: "U2", username: "bob", discriminator: "0" }, + timestamp: new Date().toISOString(), + })), + }; + const ctx = await createBaseContext({ + baseSessionKey: threadSessionKey, + route: BASE_CHANNEL_ROUTE, + messageChannelId: "thread-1", + message: { + id: "m1", + channelId: "thread-1", + content: "follow-up", + timestamp: new Date().toISOString(), + attachments: [], + }, + messageText: "follow-up", + baseText: "follow-up", + threadChannel: { id: "thread-1", name: "child-thread" }, + threadParentId: "parent-1", + client: { rest }, + channelConfig: { allowed: true, users: ["U2"] }, + }); + + await runProcessDiscordMessage(ctx); + + expect(rest.get).toHaveBeenCalled(); + expect(getLastDispatchCtx()).toMatchObject({ + SessionKey: threadSessionKey, + MessageThreadId: "thread-1", + }); + expect(getLastDispatchCtx()?.ThreadStarterBody).toBeUndefined(); + }); }); describe("processDiscordMessage draft streaming", () => {