diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 84d1e5d2f..9606b0388 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -272,4 +272,108 @@ describe("discord tool result dispatch", () => { expect(capturedCtx?.ThreadStarterBody).toContain("starter message"); expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general"); }); + + it("scopes thread sessions to the routed agent", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + + let capturedCtx: + | { + SessionKey?: string; + ParentSessionKey?: string; + } + | undefined; + dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { + capturedCtx = ctx; + dispatcher.sendFinalReply({ text: "hi" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + + const cfg = { + agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + session: { store: "/tmp/clawdbot-sessions.json" }, + messages: { responsePrefix: "PFX" }, + discord: { + dm: { enabled: true, policy: "open" }, + guilds: { "*": { requireMention: false } }, + }, + routing: { + allowFrom: [], + bindings: [ + { agentId: "support", match: { provider: "discord", guildId: "g1" } }, + ], + }, + } as ReturnType; + + const handler = createDiscordMessageHandler({ + cfg, + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { "*": { requireMention: false } }, + }); + + const threadChannel = { + type: ChannelType.GuildText, + name: "thread-name", + parentId: "p1", + parent: { id: "p1", name: "general" }, + isThread: () => true, + }; + + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, + name: "thread-name", + }), + rest: { + get: vi.fn().mockResolvedValue({ + content: "starter message", + author: { id: "u1", username: "Alice", discriminator: "0001" }, + timestamp: new Date().toISOString(), + }), + }, + } as unknown as Client; + + await handler( + { + message: { + id: "m5", + content: "thread reply", + channelId: "t1", + channel: threadChannel, + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, + }, + author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, + member: { displayName: "Bob" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + }, + client, + ); + + expect(capturedCtx?.SessionKey).toBe("agent:support:discord:channel:t1"); + expect(capturedCtx?.ParentSessionKey).toBe( + "agent:support:discord:channel:p1", + ); + }); }); diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index e7d7a3338..540a065b8 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -388,6 +388,73 @@ describe("monitorSlackProvider tool results", () => { expect(ctx.ThreadLabel).toContain("Slack thread #general"); }); + it("scopes thread session keys to the routed agent", async () => { + replyMock.mockResolvedValue({ text: "ok" }); + config = { + messages: { responsePrefix: "PFX" }, + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + channels: { C1: { allow: true, requireMention: false } }, + }, + routing: { + allowFrom: [], + bindings: [ + { agentId: "support", match: { provider: "slack", teamId: "T1" } }, + ], + }, + }; + + const client = getSlackClient(); + if (client?.auth?.test) { + client.auth.test.mockResolvedValue({ + user_id: "bot-user", + team_id: "T1", + }); + } + if (client?.conversations?.info) { + client.conversations.info.mockResolvedValue({ + channel: { name: "general", is_channel: true }, + }); + } + + 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; + }; + expect(ctx.SessionKey).toBe( + "agent:support:slack:channel:C1:thread:111.222", + ); + expect(ctx.ParentSessionKey).toBe("agent:support:slack:channel:C1"); + }); + it("keeps replies in channel root when message is not threaded", async () => { replyMock.mockResolvedValue({ text: "root reply" });