Skip to content

Commit adb1979

Browse files
author
Peter Steinberger
committed
fix(slack): canonicalize inherited thread replies
1 parent d229a0e commit adb1979

6 files changed

Lines changed: 34 additions & 92 deletions

File tree

extensions/slack/src/channel.test.ts

Lines changed: 27 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -475,77 +475,6 @@ describe("slackPlugin status", () => {
475475
});
476476
});
477477

478-
it("prefers the current Slack thread session when reply is inherited (replyToIsExplicit=false)", async () => {
479-
const resolveRoute = slackPlugin.messaging?.resolveOutboundSessionRoute;
480-
if (!resolveRoute) {
481-
throw new Error("slack messaging.resolveOutboundSessionRoute unavailable");
482-
}
483-
484-
const route = await resolveRoute({
485-
cfg: {} as OpenClawConfig,
486-
agentId: "main",
487-
target: "channel:C1",
488-
currentSessionKey: "agent:main:slack:channel:C1:thread:1712345678.123456",
489-
replyToId: "1712345688.654321",
490-
replyToIsExplicit: false,
491-
});
492-
493-
// Inherited child reply timestamp should not create a new session;
494-
// it should route into the existing thread session.
495-
expectRecordFields(route, "Slack route (inherited reply)", {
496-
sessionKey: "agent:main:slack:channel:c1:thread:1712345678.123456",
497-
baseSessionKey: "agent:main:slack:channel:c1",
498-
threadId: "1712345678.123456",
499-
});
500-
});
501-
502-
it("keeps explicit reply-to precedence when replyToIsExplicit=true", async () => {
503-
const resolveRoute = slackPlugin.messaging?.resolveOutboundSessionRoute;
504-
if (!resolveRoute) {
505-
throw new Error("slack messaging.resolveOutboundSessionRoute unavailable");
506-
}
507-
508-
const route = await resolveRoute({
509-
cfg: {} as OpenClawConfig,
510-
agentId: "main",
511-
target: "channel:C1",
512-
currentSessionKey: "agent:main:slack:channel:C1:thread:1712345678.123456",
513-
replyToId: "1712345688.654321",
514-
replyToIsExplicit: true,
515-
});
516-
517-
// Explicit reply targets should keep the current reply-first behavior.
518-
expectRecordFields(route, "Slack route (explicit reply)", {
519-
sessionKey: "agent:main:slack:channel:c1:thread:1712345688.654321",
520-
baseSessionKey: "agent:main:slack:channel:c1",
521-
threadId: "1712345688.654321",
522-
});
523-
});
524-
525-
it("preserves reply-first routing when replyToIsExplicit is omitted (backward compat)", async () => {
526-
const resolveRoute = slackPlugin.messaging?.resolveOutboundSessionRoute;
527-
if (!resolveRoute) {
528-
throw new Error("slack messaging.resolveOutboundSessionRoute unavailable");
529-
}
530-
531-
const route = await resolveRoute({
532-
cfg: {} as OpenClawConfig,
533-
agentId: "main",
534-
target: "channel:C1",
535-
currentSessionKey: "agent:main:slack:channel:C1:thread:1712345678.123456",
536-
replyToId: "1712345688.654321",
537-
// replyToIsExplicit intentionally omitted
538-
});
539-
540-
// Callers that haven't opted into the tri-state signal (e.g. gateway
541-
// send with an explicit replyToId) must keep reply-first routing.
542-
expectRecordFields(route, "Slack route (omitted explicitness)", {
543-
sessionKey: "agent:main:slack:channel:c1:thread:1712345688.654321",
544-
baseSessionKey: "agent:main:slack:channel:c1",
545-
threadId: "1712345688.654321",
546-
});
547-
});
548-
549478
it("canonicalizes bare Slack IM channel targets to direct user session routes", async () => {
550479
const resolveRoute = slackPlugin.messaging?.resolveOutboundSessionRoute;
551480
if (!resolveRoute) {
@@ -945,6 +874,33 @@ describe("slackPlugin outbound", () => {
945874
});
946875
});
947876

877+
it.each([
878+
{
879+
name: "inherited",
880+
replyToIsExplicit: false,
881+
expectedReplyToId: "1712345678.123456",
882+
},
883+
{ name: "explicit", replyToIsExplicit: true, expectedReplyToId: "1712345688.654321" },
884+
{ name: "unknown", replyToIsExplicit: undefined, expectedReplyToId: "1712345688.654321" },
885+
])(
886+
"routes $name child replies to $expectedReplyToId",
887+
({ replyToIsExplicit, expectedReplyToId }) => {
888+
const resolveReplyTransport = slackPlugin.threading?.resolveReplyTransport;
889+
if (!resolveReplyTransport) {
890+
throw new Error("slack threading.resolveReplyTransport unavailable");
891+
}
892+
893+
expect(
894+
resolveReplyTransport({
895+
cfg,
896+
replyToId: "1712345688.654321",
897+
threadId: "1712345678.123456",
898+
replyToIsExplicit,
899+
}),
900+
).toEqual({ replyToId: expectedReplyToId, threadId: null });
901+
},
902+
);
903+
948904
it("ignores explicit reply targets for off-mode final delivery", () => {
949905
const resolveReplyTransport = slackPlugin.threading?.resolveReplyTransport;
950906
if (!resolveReplyTransport) {

extensions/slack/src/channel.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,6 @@ async function resolveSlackOutboundSessionRoute(params: {
313313
accountId?: string | null;
314314
target: string;
315315
replyToId?: string | null;
316-
replyToIsExplicit?: boolean;
317316
threadId?: string | number | null;
318317
currentSessionKey?: string | null;
319318
}) {
@@ -375,16 +374,6 @@ async function resolveSlackOutboundSessionRoute(params: {
375374
replyToId: params.replyToId,
376375
threadId: params.threadId,
377376
currentSessionKey: params.currentSessionKey,
378-
// Inherited Slack child-reply timestamps should not create new
379-
// delivery-mirror sessions; prefer the recovered thread session
380-
// when the reply target was explicitly marked as inherited.
381-
// Omitted (undefined) explicitness preserves the old reply-first
382-
// default for callers that haven't opted into the tri-state signal
383-
// (e.g. gateway send with an explicit request replyToId).
384-
precedence:
385-
params.replyToIsExplicit === false
386-
? ["currentSession", "threadId", "replyToId"]
387-
: ["replyToId", "threadId", "currentSession"],
388377
canRecoverCurrentThread: () =>
389378
shouldRecoverSlackThreadFromCurrentSession({
390379
cfg: params.cfg,
@@ -829,10 +818,14 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
829818
toolContext,
830819
}),
831820
),
832-
resolveReplyTransport: ({ threadId, replyToId, replyDelivery }) => {
821+
resolveReplyTransport: ({ threadId, replyToId, replyToIsExplicit, replyDelivery }) => {
822+
const allowedReplyToId = replyDelivery?.replyToMode === "off" ? undefined : replyToId;
823+
// Slack's thread_ts identifies the root. Only known inherited replies may let
824+
// that root replace a child timestamp; explicit and unknown callers stay reply-first.
825+
const preferThreadId = replyToIsExplicit === false;
833826
const resolvedReplyToId = resolveSlackThreadTsValue({
834-
replyToId: replyDelivery?.replyToMode === "off" ? undefined : replyToId,
835-
threadId,
827+
replyToId: preferThreadId ? threadId : allowedReplyToId,
828+
threadId: preferThreadId ? allowedReplyToId : threadId,
836829
});
837830
return {
838831
replyToId:

src/channels/plugins/types.core.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -649,8 +649,6 @@ export type ChannelMessagingAdapter = {
649649
source: "normalized" | "directory";
650650
};
651651
replyToId?: string | null;
652-
/** True when replyToId came from an explicit action-payload replyTo field. */
653-
replyToIsExplicit?: boolean;
654652
threadId?: string | number | null;
655653
}) => ChannelOutboundSessionRoute | Promise<ChannelOutboundSessionRoute | null> | null;
656654
};

src/infra/outbound/message-action-runner.threading.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,12 @@ describe("message action threading helpers", () => {
123123
threadId: threadId ?? null,
124124
}),
125125
resolveOutboundSessionRoute,
126-
replyToIsExplicit: true,
127126
ensureOutboundSessionEntry,
128127
});
129128

130129
expect(resolveOutboundSessionRoute).toHaveBeenCalledOnce();
131130
expect(firstMockArg(resolveOutboundSessionRoute)).toMatchObject({
132131
replyToId: "root-42",
133-
replyToIsExplicit: true,
134132
threadId: "root-42",
135133
});
136134
});

src/infra/outbound/message-action-threading.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,6 @@ export async function prepareOutboundMirrorRoute(params: {
211211
currentSessionKey: params.currentSessionKey,
212212
resolvedTarget: params.resolvedTarget,
213213
replyToId,
214-
replyToIsExplicit: params.replyToIsExplicit,
215214
threadId: resolvedThreadId,
216215
})
217216
: null;

src/infra/outbound/outbound-session.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,6 @@ export type ResolveOutboundSessionRouteParams = {
3939
currentSessionKey?: string;
4040
resolvedTarget?: ResolvedMessagingTarget;
4141
replyToId?: string | null;
42-
/** True when replyToId came from an explicit action-payload replyTo field. */
43-
replyToIsExplicit?: boolean;
4442
threadId?: string | number | null;
4543
};
4644

0 commit comments

Comments
 (0)