Skip to content

Commit 3e44832

Browse files
ffluk3Copilot
andcommitted
fix(auto-reply): keep threaded Slack replies route-bound
Preserve implicit Slack thread ids in messaging-tool send telemetry and require thread-aware dedupe for later automatic replies and followups. Adds regression coverage for Slack thread routing while keeping Telegram topic suppression behavior intact. Refs: #90044 Co-authored-by: Copilot <[email protected]>
1 parent 0b8aabe commit 3e44832

11 files changed

Lines changed: 190 additions & 3 deletions

src/agents/embedded-agent-subscribe.handlers.tools.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
HEARTBEAT_RESPONSE_TOOL_NAME,
1111
normalizeHeartbeatToolResponse,
1212
} from "../auto-reply/heartbeat-tool-response.js";
13+
import { parseSessionThreadInfoFast } from "../config/sessions/thread-info.js";
1314
import type {
1415
AgentApprovalEventData,
1516
AgentCommandOutputEventData,
@@ -1023,7 +1024,9 @@ export function handleToolExecutionStart(
10231024
const argsRecord = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
10241025
const isMessagingSend = isMessagingToolSendAction(toolName, argsRecord);
10251026
if (isMessagingSend) {
1026-
const sendTarget = extractMessagingToolSend(toolName, argsRecord);
1027+
const sendTarget = extractMessagingToolSend(toolName, argsRecord, {
1028+
currentThreadId: parseSessionThreadInfoFast(ctx.params.sessionKey).threadId,
1029+
});
10271030
if (sendTarget) {
10281031
ctx.state.pendingMessagingTargets.set(toolCallId, sendTarget);
10291032
}

src/agents/embedded-agent-subscribe.tools.extract.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,22 @@ describe("extractMessagingToolSend", () => {
144144
expect(result?.threadImplicit).toBe(true);
145145
});
146146

147+
it("captures the active session thread for implicit threaded sends", () => {
148+
const result = extractMessagingToolSend(
149+
"message",
150+
{
151+
action: "send",
152+
provider: "telegram",
153+
to: "123",
154+
content: "done",
155+
},
156+
{ currentThreadId: "456" },
157+
);
158+
159+
expect(result?.threadImplicit).toBe(true);
160+
expect(result?.threadId).toBe("456");
161+
});
162+
147163
it("keeps provider-tool extracted thread id evidence", () => {
148164
const result = extractMessagingToolSend("slack", {
149165
action: "sendMessage",

src/agents/embedded-agent-subscribe.tools.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,7 @@ function resolveMessageToolTarget(args: Record<string, unknown>): string | undef
612612
export function extractMessagingToolSend(
613613
toolName: string,
614614
args: Record<string, unknown>,
615+
options?: { currentThreadId?: string },
615616
): MessagingToolSend | undefined {
616617
// Provider docking: new provider tools must implement plugin.actions.extractToolSend.
617618
const action = normalizeOptionalString(args.action) ?? "";
@@ -636,13 +637,16 @@ export function extractMessagingToolSend(
636637
!threadId &&
637638
!threadSuppressed &&
638639
Boolean(providerId && getChannelPlugin(providerId)?.threading?.resolveAutoThreadId);
640+
const currentThreadId = threadImplicit
641+
? normalizeOptionalString(options?.currentThreadId)
642+
: undefined;
639643
return to
640644
? {
641645
tool: toolName,
642646
provider,
643647
accountId,
644648
to,
645-
...(threadId ? { threadId } : {}),
649+
...((threadId ?? currentThreadId) ? { threadId: threadId ?? currentThreadId } : {}),
646650
...(threadImplicit ? { threadImplicit: true } : {}),
647651
...(threadSuppressed ? { threadSuppressed: true } : {}),
648652
}

src/auto-reply/reply/agent-runner-payloads.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export async function buildReplyPayloads(params: {
175175
originatingChannel?: OriginatingChannelType;
176176
originatingTo?: string;
177177
accountId?: string;
178+
originatingThreadId?: string | number;
178179
extractMarkdownImages?: boolean;
179180
normalizeMediaPaths?: (payload: ReplyPayload) => Promise<ReplyPayload>;
180181
}): Promise<{ replyPayloads: ReplyPayload[]; didLogHeartbeatStrip: boolean }> {
@@ -279,6 +280,7 @@ export async function buildReplyPayloads(params: {
279280
accountId: resolveOriginAccountId({
280281
originatingAccountId: params.accountId,
281282
}),
283+
originatingThreadId: params.originatingThreadId,
282284
}) ?? {
283285
shouldDedupePayloads: shouldCheckMessagingToolDedupe && messagingToolSentTargets.length === 0,
284286
matchingRoute: false,

src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2215,6 +2215,52 @@ describe("runReplyAgent messaging tool dedupe", () => {
22152215

22162216
expectReplyText(result, "hello world!");
22172217
});
2218+
2219+
it("keeps threaded Slack replies when the messaging tool sent to another thread", async () => {
2220+
runEmbeddedAgentMock.mockResolvedValueOnce({
2221+
payloads: [{ text: "hello world!" }],
2222+
messagingToolSentTexts: ["hello world!"],
2223+
messagingToolSentTargets: [
2224+
{
2225+
tool: "message",
2226+
provider: "slack",
2227+
to: "channel:C1",
2228+
threadId: "171.333",
2229+
text: "hello world!",
2230+
},
2231+
],
2232+
meta: {},
2233+
});
2234+
2235+
const result = await createRun("slack", {
2236+
sessionKey: "agent:main:slack:channel:C1:thread:171.222",
2237+
});
2238+
2239+
expectReplyText(result, "hello world!");
2240+
});
2241+
2242+
it("drops threaded Slack duplicates only when the messaging tool sent to the same thread", async () => {
2243+
runEmbeddedAgentMock.mockResolvedValueOnce({
2244+
payloads: [{ text: "hello world!" }],
2245+
messagingToolSentTexts: ["hello world!"],
2246+
messagingToolSentTargets: [
2247+
{
2248+
tool: "message",
2249+
provider: "slack",
2250+
to: "channel:C1",
2251+
threadId: "171.222",
2252+
text: "hello world!",
2253+
},
2254+
],
2255+
meta: {},
2256+
});
2257+
2258+
const result = await createRun("slack", {
2259+
sessionKey: "agent:main:slack:channel:C1:thread:171.222",
2260+
});
2261+
2262+
expect(result).toBeUndefined();
2263+
});
22182264
});
22192265

22202266
describe("runReplyAgent reminder commitment guard", () => {

src/auto-reply/reply/agent-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1543,6 +1543,7 @@ export async function runReplyAgent(params: {
15431543
to: sessionCtx.To,
15441544
}),
15451545
accountId: sessionCtx.AccountId,
1546+
originatingThreadId: replyRouteThreadId,
15461547
normalizeMediaPaths: replyMediaContext.normalizePayload,
15471548
});
15481549
const replyPayloads = payloadResult.replyPayloads.map((payload) =>
@@ -1938,6 +1939,7 @@ export async function runReplyAgent(params: {
19381939
to: sessionCtx.To,
19391940
}),
19401941
accountId: sessionCtx.AccountId,
1942+
originatingThreadId: replyRouteThreadId,
19411943
normalizeMediaPaths: replyMediaContext.normalizePayload,
19421944
});
19431945
const { replyPayloads } = payloadResult;

src/auto-reply/reply/followup-delivery.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,49 @@ describe("resolveFollowupDeliveryPayloads", () => {
177177
).toStrictEqual([]);
178178
});
179179

180+
it("keeps top-level Slack replies when the messaging tool only sent into a thread", () => {
181+
expect(
182+
resolveFollowupDeliveryPayloads({
183+
cfg: baseConfig,
184+
payloads: [{ text: "hello world!" }],
185+
messageProvider: "slack",
186+
originatingTo: "channel:C1",
187+
sentTexts: ["hello world!"],
188+
sentTargets: [
189+
{
190+
tool: "message",
191+
provider: "slack",
192+
to: "channel:C1",
193+
threadId: "171.222",
194+
text: "hello world!",
195+
},
196+
],
197+
}),
198+
).toEqual([{ text: "hello world!" }]);
199+
});
200+
201+
it("dedupes Slack replies only when the messaging tool sent into the same thread", () => {
202+
expect(
203+
resolveFollowupDeliveryPayloads({
204+
cfg: baseConfig,
205+
payloads: [{ text: "hello world!" }],
206+
messageProvider: "slack",
207+
originatingTo: "channel:C1",
208+
originatingThreadId: "171.222",
209+
sentTexts: ["hello world!"],
210+
sentTargets: [
211+
{
212+
tool: "message",
213+
provider: "slack",
214+
to: "channel:C1",
215+
threadId: "171.222",
216+
text: "hello world!",
217+
},
218+
],
219+
}),
220+
).toStrictEqual([]);
221+
});
222+
180223
it("delivers distinct replies when originating channel resolves the provider", () => {
181224
expect(
182225
resolveFollowupDeliveryPayloads({

src/auto-reply/reply/followup-delivery.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function resolveFollowupDeliveryPayloads(params: {
3131
originatingChannel?: string;
3232
originatingChatType?: string | null;
3333
originatingTo?: string;
34+
originatingThreadId?: string | number;
3435
sentMediaUrls?: string[];
3536
sentTargets?: MessagingToolSend[];
3637
sentTexts?: string[];
@@ -74,6 +75,7 @@ export function resolveFollowupDeliveryPayloads(params: {
7475
accountId: resolveOriginAccountId({
7576
originatingAccountId: params.originatingAccountId,
7677
}),
78+
originatingThreadId: params.originatingThreadId,
7779
});
7880
const sentMediaUrlFallback = params.sentMediaUrls ?? [];
7981
const sentTextFallback = params.sentTexts ?? [];

src/auto-reply/reply/followup-runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,7 @@ export function createFollowupRunner(params: {
10981098
originatingChannel: queued.originatingChannel,
10991099
originatingChatType: queued.originatingChatType,
11001100
originatingTo: queued.originatingTo,
1101+
originatingThreadId: queued.originatingThreadId,
11011102
sentMediaUrls: runResult.messagingToolSentMediaUrls,
11021103
sentTargets: runResult.messagingToolSentTargets,
11031104
sentTexts: runResult.messagingToolSentTexts,

src/auto-reply/reply/reply-payloads-dedupe.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ function normalizeProviderForComparison(value?: string): string | undefined {
127127
return lowered;
128128
}
129129

130-
function normalizeThreadIdForComparison(value?: string): string | undefined {
130+
function normalizeThreadIdForComparison(value?: unknown): string | undefined {
131131
return stringifyRouteThreadId(value);
132132
}
133133

@@ -181,6 +181,7 @@ function targetsMatchForDedupe(params: {
181181
provider: string;
182182
originTarget: string;
183183
targetKey: string;
184+
originThreadId?: string;
184185
targetThreadId?: string;
185186
}): boolean {
186187
const pluginMatch = getChannelPlugin(params.provider)?.outbound?.targetsMatchForReplySuppression;
@@ -191,6 +192,9 @@ function targetsMatchForDedupe(params: {
191192
targetThreadId: normalizeThreadIdForComparison(params.targetThreadId),
192193
});
193194
}
195+
if (params.originThreadId || params.targetThreadId) {
196+
return false;
197+
}
194198
return params.targetKey === params.originTarget;
195199
}
196200

@@ -199,6 +203,7 @@ export function shouldDedupeMessagingToolRepliesForRoute(params: {
199203
messagingToolSentTargets?: MessagingToolSend[];
200204
originatingTo?: string;
201205
accountId?: string;
206+
originatingThreadId?: string | number;
202207
}): boolean {
203208
return getMatchingMessagingToolReplyTargets(params).length > 0;
204209
}
@@ -208,6 +213,7 @@ export function getMatchingMessagingToolReplyTargets(params: {
208213
messagingToolSentTargets?: MessagingToolSend[];
209214
originatingTo?: string;
210215
accountId?: string;
216+
originatingThreadId?: string | number;
211217
}): MessagingToolSend[] {
212218
const provider = normalizeProviderForComparison(params.messageProvider);
213219
if (!provider) {
@@ -237,6 +243,7 @@ export function getMatchingMessagingToolReplyTargets(params: {
237243
provider,
238244
rawTarget: originRawTarget,
239245
accountId: routeAccount,
246+
threadId: stringifyRouteThreadId(params.originatingThreadId),
240247
});
241248
if (!originRoute) {
242249
return false;
@@ -257,6 +264,7 @@ export function getMatchingMessagingToolReplyTargets(params: {
257264
provider,
258265
originTarget: originRoute.to,
259266
targetKey: targetRoute.to,
267+
originThreadId: normalizeThreadIdForComparison(originRoute.threadId),
260268
targetThreadId: target.threadId,
261269
});
262270
});
@@ -276,13 +284,15 @@ export function resolveMessagingToolPayloadDedupe(params: {
276284
messagingToolSentTargets?: MessagingToolSend[];
277285
originatingTo?: string;
278286
accountId?: string;
287+
originatingThreadId?: string | number;
279288
}): MessagingToolPayloadDedupeDecision {
280289
const sentTargets = params.messagingToolSentTargets ?? [];
281290
const matchingTargets = getMatchingMessagingToolReplyTargets({
282291
messageProvider: params.messageProvider,
283292
messagingToolSentTargets: sentTargets,
284293
originatingTo: params.originatingTo,
285294
accountId: params.accountId,
295+
originatingThreadId: params.originatingThreadId,
286296
});
287297
const matchingRoute = matchingTargets.length > 0;
288298
const routeSentTexts = matchingTargets.flatMap((target) =>

0 commit comments

Comments
 (0)