From 2b9d5e6e30be043de07192a9d7d53faae0483a24 Mon Sep 17 00:00:00 2001 From: Ben Newton Date: Thu, 12 Feb 2026 07:13:58 -0500 Subject: [PATCH] feat(slack): include thread metadata (thread_ts, parent_user_id) in agent context Adds thread_ts and parent_user_id to the Slack message footer for thread replies, giving agents awareness of thread context. Top-level messages remain unchanged. Includes tests verifying: - Thread replies include thread_ts and parent_user_id in footer - Top-level messages exclude thread metadata --- .../prepare.inbound-contract.test.ts | 145 ++++++++++++++++++ src/slack/monitor/message-handler/prepare.ts | 5 +- 2 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts b/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts index ceb056d3d38..e5080d22e95 100644 --- a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts +++ b/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts @@ -235,4 +235,149 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared).toBeTruthy(); expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000"); }); + + it("includes thread_ts and parent_user_id metadata in thread replies", async () => { + const slackCtx = createSlackMonitorContext({ + cfg: { + channels: { slack: { enabled: true } }, + } as OpenClawConfig, + accountId: "default", + botToken: "token", + app: { client: {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender", + mainKey: "main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "thread", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1024, + removeAckAfterReply: false, + }); + // oxlint-disable-next-line typescript/no-explicit-any + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const account: ResolvedSlackAccount = { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + config: {}, + }; + + const message: SlackMessageEvent = { + channel: "D123", + channel_type: "im", + user: "U1", + text: "this is a reply", + ts: "1.002", + thread_ts: "1.000", + parent_user_id: "U2", + } as SlackMessageEvent; + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + // Verify thread metadata is in the message footer + expect(prepared!.ctxPayload.Body).toMatch( + /\[slack message id: 1\.002 channel: D123 thread_ts: 1\.000 parent_user: U2\]/, + ); + }); + + it("excludes thread_ts from top-level messages", async () => { + const slackCtx = createSlackMonitorContext({ + cfg: { + channels: { slack: { enabled: true } }, + } as OpenClawConfig, + accountId: "default", + botToken: "token", + app: { client: {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender", + mainKey: "main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "thread", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1024, + removeAckAfterReply: false, + }); + // oxlint-disable-next-line typescript/no-explicit-any + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const account: ResolvedSlackAccount = { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + config: {}, + }; + + const message: SlackMessageEvent = { + channel: "D123", + channel_type: "im", + user: "U1", + text: "hello", + ts: "1.000", + } as SlackMessageEvent; + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + // Top-level messages should NOT have thread_ts in the footer + expect(prepared!.ctxPayload.Body).toMatch(/\[slack message id: 1\.000 channel: D123\]$/); + expect(prepared!.ctxPayload.Body).not.toContain("thread_ts"); + }); }); diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 900a0484c9b..52fc509c350 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -399,7 +399,10 @@ export async function prepareSlackMessage(params: { GroupSubject: isRoomish ? roomLabel : undefined, From: slackFrom, }) ?? (isDirectMessage ? senderName : roomLabel); - const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`; + const threadInfo = message.thread_ts + ? ` thread_ts: ${message.thread_ts}${message.parent_user_id ? ` parent_user: ${message.parent_user_id}` : ""}` + : ""; + const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}${threadInfo}]`; const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId: route.agentId, });