Threads: add Slack/Discord thread sessions

This commit is contained in:
Shadow
2026-01-07 09:02:20 -06:00
committed by Peter Steinberger
parent 422477499c
commit 7e5cef29a0
17 changed files with 670 additions and 27 deletions

View File

@@ -57,6 +57,7 @@ vi.mock("@slack/bolt", () => {
info: vi.fn().mockResolvedValue({
channel: { name: "dm", is_im: true },
}),
replies: vi.fn().mockResolvedValue({ messages: [] }),
},
users: {
info: vi.fn().mockResolvedValue({
@@ -283,6 +284,114 @@ describe("monitorSlackProvider tool results", () => {
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" });
});
it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => {
const { resolveSessionKey } = await import("../config/sessions.js");
vi.mocked(resolveSessionKey).mockReturnValue("main");
replyMock.mockResolvedValue({ text: "thread reply" });
const controller = new AbortController();
const run = monitorSlackProvider({
botToken: "bot-token",
appToken: "app-token",
abortSignal: controller.signal,
});
await waitForEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
await handler({
event: {
type: "message",
user: "U1",
text: "hello",
ts: "123",
thread_ts: "123",
parent_user_id: "U2",
channel: "C1",
channel_type: "im",
},
});
await flush();
controller.abort();
await run;
expect(replyMock).toHaveBeenCalledTimes(1);
const ctx = replyMock.mock.calls[0]?.[0] as {
SessionKey?: string;
ParentSessionKey?: string;
};
expect(ctx.SessionKey).toBe("slack:thread:C1:123");
expect(ctx.ParentSessionKey).toBe("main");
});
it("forks thread sessions and injects starter context", async () => {
const { resolveSessionKey } = await import("../config/sessions.js");
vi.mocked(resolveSessionKey).mockReturnValue("slack:channel:C1");
replyMock.mockResolvedValue({ text: "ok" });
const client = getSlackClient();
if (client?.conversations?.info) {
client.conversations.info.mockResolvedValue({
channel: { name: "general", is_channel: true },
});
}
if (client?.conversations?.replies) {
client.conversations.replies.mockResolvedValue({
messages: [{ text: "starter message", user: "U2", ts: "111.222" }],
});
}
config = {
messages: { responsePrefix: "PFX" },
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels: { C1: { allow: true, requireMention: false } },
},
routing: { allowFrom: [] },
};
const controller = new AbortController();
const run = monitorSlackProvider({
botToken: "bot-token",
appToken: "app-token",
abortSignal: controller.signal,
});
await waitForEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
await handler({
event: {
type: "message",
user: "U1",
text: "thread reply",
ts: "123.456",
thread_ts: "111.222",
channel: "C1",
channel_type: "channel",
},
});
await flush();
controller.abort();
await run;
expect(replyMock).toHaveBeenCalledTimes(1);
const ctx = replyMock.mock.calls[0]?.[0] as {
SessionKey?: string;
ParentSessionKey?: string;
ThreadStarterBody?: string;
ThreadLabel?: string;
};
expect(ctx.SessionKey).toBe("slack:thread:C1:111.222");
expect(ctx.ParentSessionKey).toBe("slack:channel:C1");
expect(ctx.ThreadStarterBody).toContain("starter message");
expect(ctx.ThreadLabel).toContain("Slack thread #general");
});
it("keeps replies in channel root when message is not threaded", async () => {
replyMock.mockResolvedValue({ text: "root reply" });