Files
openclaw/extensions/slack/src/action-threading.test.ts
sandieman2 c67dc59b02 fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread (#90943)
* fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread

Two core bugs caused composed replies to be silently dropped (no delivery,
no error) when a second message arrived in the same thread mid-run:

1. dispatch-from-config: ensureDispatchReplyOperation only kept the
   dispatch-owned operation authoritative while it had no result. Once
   runReplyAgent completed the operation to drain queued follow-ups, a
   second same-thread inbound could claim the session and the first final
   reply would try to re-acquire the lane instead of finishing delivery,
   deadlocking behind the queued work. Keep the dispatch-owned operation
   authoritative through final delivery.

2. reply-payloads-dedupe: messaging-tool reply dedupe compared only the
   channel target, not the routed thread, so a send in one thread could
   suppress a later reply in a different thread. Thread the routed thread
   id through buildReplyPayloads + follow-up delivery and only fall back to
   channel-only matching for providers without a thread-aware suppression
   matcher when neither side carries thread evidence.

Adds regression tests; existing Telegram topic-suppression behavior is
preserved by gating the thread guard to providers lacking a plugin matcher.

* fix(reply): preserve threaded message delivery evidence

* fix(reply): dedupe final payloads by delivery route

* fix(slack): preserve native send thread evidence

* fix(reply): preserve explicit reply thread evidence

* fix(reply): align explicit reply route dedupe

* fix(reply): preserve delivery lane through final dispatch

* fix(mattermost): preserve threaded tool send routes

* chore(plugin-sdk): refresh API baseline

* fix(reply): align final delivery route dedupe

* fix(reply): gate followups on final delivery

* fix(reply): keep send receipts private

* fix(reply): infer implicit message provider

* fix(reply): align routed threading policy

* fix(reply): preserve queued delivery context

* fix(reply): hydrate queued system event routes

* fix(reply): hydrate queued execution routes

* fix(reply): scope final delivery barriers

* fix(slack): preserve DM target aliases

* fix(reply): mirror resolved source thread routes

* fix(mattermost): retain delayed delivery barrier

* fix(codex): separate message routing from tool policy

* fix(reply): consume normalized Slack DM targets once

* fix(slack): remove stale target alias

* style(reply): satisfy changed lint gates

* fix(mattermost): preserve explicit reply targets

* test: align Slack reply branch checks

* fix(reply): persist overflow summaries to admitted session

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-14 09:11:05 -07:00

121 lines
3.4 KiB
TypeScript

// Slack tests cover action threading plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveSlackAutoThreadId } from "./action-threading.js";
type SlackThreadingToolContext = {
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
sameChannelThreadRequired?: boolean;
};
function createToolContext(
overrides: Partial<SlackThreadingToolContext> = {},
): SlackThreadingToolContext {
return {
currentChannelId: "C123",
currentThreadTs: "thread-1",
replyToMode: "all",
...overrides,
};
}
describe("resolveSlackAutoThreadId", () => {
it("uses the active thread only for matching channel targets", () => {
expect(
resolveSlackAutoThreadId({
to: "#c123",
toolContext: createToolContext(),
}),
).toBe("thread-1");
expect(
resolveSlackAutoThreadId({
to: "channel:C999",
toolContext: createToolContext(),
}),
).toBeUndefined();
expect(
resolveSlackAutoThreadId({
to: "user:U123",
toolContext: createToolContext(),
}),
).toBeUndefined();
});
it("threads first matching prefixed channel target with bare current channel", () => {
const hasRepliedRef = { value: false };
expect(
resolveSlackAutoThreadId({
to: "channel:C123",
toolContext: createToolContext({
replyToMode: "first",
hasRepliedRef,
}),
}),
).toBe("thread-1");
expect(hasRepliedRef.value).toBe(false);
});
it("uses the active thread for matching user targets", () => {
expect(
resolveSlackAutoThreadId({
to: "user:U123",
toolContext: createToolContext({
currentChannelId: "slack:U123",
}),
}),
).toBe("thread-1");
});
it("matches either native or routable DM targets", () => {
const context = createToolContext({
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
});
expect(resolveSlackAutoThreadId({ to: "user:U123", toolContext: context })).toBe("thread-1");
expect(resolveSlackAutoThreadId({ to: "U123", toolContext: context })).toBe("thread-1");
expect(resolveSlackAutoThreadId({ to: "D123", toolContext: context })).toBe("thread-1");
expect(resolveSlackAutoThreadId({ to: "user:U999", toolContext: context })).toBeUndefined();
});
it("skips auto-threading when reply mode or thread context blocks it", () => {
expect(
resolveSlackAutoThreadId({
to: "C123",
toolContext: createToolContext({
replyToMode: "first",
hasRepliedRef: { value: true },
}),
}),
).toBeUndefined();
expect(
resolveSlackAutoThreadId({
to: "C123",
toolContext: createToolContext({ replyToMode: "off" }),
}),
).toBeUndefined();
expect(
resolveSlackAutoThreadId({
to: "C123",
toolContext: createToolContext({ currentThreadTs: undefined }),
}),
).toBeUndefined();
});
it("fails closed for same-channel threaded replies when the thread timestamp is missing", () => {
expect(() =>
resolveSlackAutoThreadId({
to: "C123",
toolContext: createToolContext({
currentThreadTs: undefined,
sameChannelThreadRequired: true,
}),
}),
).toThrow("Slack thread context is required");
});
});