From 5356adba8f7dafebb3954b89866d73702cbc671e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 01:09:25 +0100 Subject: [PATCH] fix: keep Slack thread replies in thread --- CHANGELOG.md | 1 + src/discord/monitor.tool-result.test.ts | 9 +++---- src/slack/monitor.tool-result.test.ts | 34 +++++++++++++++++++++++++ src/slack/monitor.ts | 11 +++++--- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f5ff03d..2e4942cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Auto-reply: unify tool/block/final delivery across providers and apply consistent heartbeat/prefix handling. Thanks @MSch for PR #225 (superseded commit 92c953d0749143eb2a3f31f3cd6ad0e8eabf48c3). - Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman - WhatsApp: set sender E.164 for direct chats so owner commands work in DMs. +- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251. ### Maintenance - Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome. diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 3b95db93b..867c592c3 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -31,12 +31,11 @@ vi.mock("../config/sessions.js", () => ({ vi.mock("discord.js", () => { const handlers = new Map void>>(); - let lastClient: Client | null = null; - class Client { + static lastClient: Client | null = null; user = { id: "bot-id", tag: "bot#1" }; constructor() { - lastClient = this; + Client.lastClient = this; } on(event: string, handler: (...args: unknown[]) => void) { if (!handlers.has(event)) handlers.set(event, new Set()); @@ -50,7 +49,7 @@ vi.mock("discord.js", () => { } emit(event: string, ...args: unknown[]) { for (const handler of handlers.get(event) ?? []) { - void handler(...args); + void Promise.resolve(handler(...args)); } } login = vi.fn().mockResolvedValue(undefined); @@ -59,7 +58,7 @@ vi.mock("discord.js", () => { return { Client, - __getLastClient: () => lastClient, + __getLastClient: () => Client.lastClient, Events: { ClientReady: "ready", Error: "error", diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index ade080f2b..99ec296dc 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -122,4 +122,38 @@ describe("monitorSlackProvider tool results", () => { expect(sendMock.mock.calls[0][1]).toBe("PFX tool update"); expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + + it("threads replies when incoming message is in a thread", async () => { + replyMock.mockResolvedValue({ text: "thread reply" }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + thread_ts: "456", + channel: "C1", + channel_type: "im", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" }); + }); }); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 4e26961d8..f3929881f 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -700,6 +700,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ); } + // Only thread replies if the incoming message was in a thread. + const incomingThreadTs = message.thread_ts; + const dispatcher = createReplyDispatcher({ responsePrefix: cfg.messages?.responsePrefix, deliver: async (payload) => { @@ -709,6 +712,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { token: botToken, runtime, textLimit, + threadTs: incomingThreadTs, }); }, onError: (err, info) => { @@ -1379,6 +1383,7 @@ async function deliverReplies(params: { token: string; runtime: RuntimeEnv; textLimit: number; + threadTs?: string; }) { const chunkLimit = Math.min(params.textLimit, 4000); for (const payload of params.replies) { @@ -1389,12 +1394,11 @@ async function deliverReplies(params: { if (mediaList.length === 0) { for (const chunk of chunkText(text, chunkLimit)) { - const threadTs = undefined; const trimmed = chunk.trim(); if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue; await sendMessageSlack(params.target, trimmed, { token: params.token, - threadTs, + threadTs: params.threadTs, }); } } else { @@ -1402,11 +1406,10 @@ async function deliverReplies(params: { for (const mediaUrl of mediaList) { const caption = first ? text : ""; first = false; - const threadTs = undefined; await sendMessageSlack(params.target, caption, { token: params.token, mediaUrl, - threadTs, + threadTs: params.threadTs, }); } }