feat(slack): land thread-ownership from @DarlingtonDeveloper (#15775)

Land PR #15775 by @DarlingtonDeveloper:
- add thread-ownership plugin and Slack message_sending hook wiring
- include regression tests and changelog update

Co-authored-by: Mike <108890394+DarlingtonDeveloper@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-02-13 23:37:05 +00:00
parent 874ff7089c
commit 51296e770c
6 changed files with 513 additions and 2 deletions

View File

@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../slack/send.js", () => ({
sendMessageSlack: vi.fn().mockResolvedValue({ ts: "1234.5678", channel: "C123" }),
}));
vi.mock("../../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(),
}));
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { sendMessageSlack } from "../../../slack/send.js";
import { slackOutbound } from "./slack.js";
describe("slack outbound hook wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls send without hooks when no hooks registered", async () => {
vi.mocked(getGlobalHookRunner).mockReturnValue(null);
await slackOutbound.sendText({
to: "C123",
text: "hello",
accountId: "default",
replyToId: "1111.2222",
});
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", {
threadTs: "1111.2222",
accountId: "default",
});
});
it("calls message_sending hook before sending", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(true),
runMessageSending: vi.fn().mockResolvedValue(undefined),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
await slackOutbound.sendText({
to: "C123",
text: "hello",
accountId: "default",
replyToId: "1111.2222",
});
expect(mockRunner.hasHooks).toHaveBeenCalledWith("message_sending");
expect(mockRunner.runMessageSending).toHaveBeenCalledWith(
{ to: "C123", content: "hello", metadata: { threadTs: "1111.2222", channelId: "C123" } },
{ channelId: "slack", accountId: "default" },
);
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", {
threadTs: "1111.2222",
accountId: "default",
});
});
it("cancels send when hook returns cancel:true", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(true),
runMessageSending: vi.fn().mockResolvedValue({ cancel: true }),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
const result = await slackOutbound.sendText({
to: "C123",
text: "hello",
accountId: "default",
replyToId: "1111.2222",
});
expect(sendMessageSlack).not.toHaveBeenCalled();
expect(result.channel).toBe("slack");
});
it("modifies text when hook returns content", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(true),
runMessageSending: vi.fn().mockResolvedValue({ content: "modified" }),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
await slackOutbound.sendText({
to: "C123",
text: "original",
accountId: "default",
replyToId: "1111.2222",
});
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "modified", {
threadTs: "1111.2222",
accountId: "default",
});
});
it("skips hooks when runner has no message_sending hooks", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(false),
runMessageSending: vi.fn(),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
await slackOutbound.sendText({
to: "C123",
text: "hello",
accountId: "default",
replyToId: "1111.2222",
});
expect(mockRunner.runMessageSending).not.toHaveBeenCalled();
expect(sendMessageSlack).toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import type { ChannelOutboundAdapter } from "../types.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { sendMessageSlack } from "../../../slack/send.js";
export const slackOutbound: ChannelOutboundAdapter = {
@@ -9,7 +10,29 @@ export const slackOutbound: ChannelOutboundAdapter = {
const send = deps?.sendSlack ?? sendMessageSlack;
// Use threadId fallback so routed tool notifications stay in the Slack thread.
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
const result = await send(to, text, {
let finalText = text;
// Run message_sending hooks (e.g. thread-ownership can cancel the send).
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("message_sending")) {
const hookResult = await hookRunner.runMessageSending(
{ to, content: text, metadata: { threadTs, channelId: to } },
{ channelId: "slack", accountId: accountId ?? undefined },
);
if (hookResult?.cancel) {
return {
channel: "slack",
messageId: "cancelled-by-hook",
channelId: to,
meta: { cancelled: true },
};
}
if (hookResult?.content) {
finalText = hookResult.content;
}
}
const result = await send(to, finalText, {
threadTs,
accountId: accountId ?? undefined,
});
@@ -19,7 +42,29 @@ export const slackOutbound: ChannelOutboundAdapter = {
const send = deps?.sendSlack ?? sendMessageSlack;
// Use threadId fallback so routed tool notifications stay in the Slack thread.
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
const result = await send(to, text, {
let finalText = text;
// Run message_sending hooks (e.g. thread-ownership can cancel the send).
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("message_sending")) {
const hookResult = await hookRunner.runMessageSending(
{ to, content: text, metadata: { threadTs, channelId: to, mediaUrl } },
{ channelId: "slack", accountId: accountId ?? undefined },
);
if (hookResult?.cancel) {
return {
channel: "slack",
messageId: "cancelled-by-hook",
channelId: to,
meta: { cancelled: true },
};
}
if (hookResult?.content) {
finalText = hookResult.content;
}
}
const result = await send(to, finalText, {
mediaUrl,
threadTs,
accountId: accountId ?? undefined,