diff --git a/CHANGELOG.md b/CHANGELOG.md index e237baed0..02e252410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ Docs: https://docs.clawd.bot - Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520) - Sessions: normalize session key casing to lowercase for consistent routing. - BlueBubbles: normalize group session keys for outbound mirroring. (#1520) +- Slack: match auto-thread mirroring channel ids case-insensitively. (#1520) +- Gateway: lowercase provided session keys when mirroring outbound sends. (#1520) - Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest. - Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu. - Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). diff --git a/docs/refactor/outbound-session-mirroring.md b/docs/refactor/outbound-session-mirroring.md index e9b2b8bcc..cbcf5711c 100644 --- a/docs/refactor/outbound-session-mirroring.md +++ b/docs/refactor/outbound-session-mirroring.md @@ -40,6 +40,8 @@ Outbound sends were mirrored into the *current* agent session (tool session key) - Mattermost targets now strip `@` for DM session key routing. - Zalo Personal uses DM peer kind for 1:1 targets (group only when `group:` is present). - BlueBubbles group targets strip `chat_*` prefixes to match inbound session keys. + - Slack auto-thread mirroring matches channel ids case-insensitively. + - Gateway send lowercases provided session keys before mirroring. ## Decisions - **Gateway send session derivation**: if `sessionKey` is provided, use it. If omitted, derive a sessionKey from target + default agent and mirror there. diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index abd961965..8c50da881 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -137,6 +137,34 @@ describe("gateway send mirroring", () => { ); }); + it("lowercases provided session keys for mirroring", async () => { + mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m-lower", channel: "slack" }]); + + const respond = vi.fn(); + await sendHandlers.send({ + params: { + to: "channel:C1", + message: "hi", + channel: "slack", + idempotencyKey: "idem-lower", + sessionKey: "agent:main:slack:channel:C123", + }, + respond, + context: makeContext(), + req: { type: "req", id: "1", method: "send" }, + client: null, + isWebchatConnect: () => false, + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + mirror: expect.objectContaining({ + sessionKey: "agent:main:slack:channel:c123", + }), + }), + ); + }); + it("derives a target session key when none is provided", async () => { mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m3", channel: "slack" }]); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index e4d292924..1d3adbb6f 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -145,7 +145,7 @@ export const sendHandlers: GatewayRequestHandlers = { ); const providedSessionKey = typeof request.sessionKey === "string" && request.sessionKey.trim() - ? request.sessionKey.trim() + ? request.sessionKey.trim().toLowerCase() : undefined; const derivedAgentId = resolveSessionAgentId({ config: cfg }); // If callers omit sessionKey, derive a target session key from the outbound route. diff --git a/src/infra/outbound/message-action-runner.threading.test.ts b/src/infra/outbound/message-action-runner.threading.test.ts index c8d8c5ba2..462a33cd3 100644 --- a/src/infra/outbound/message-action-runner.threading.test.ts +++ b/src/infra/outbound/message-action-runner.threading.test.ts @@ -89,4 +89,30 @@ describe("runMessageAction Slack threading", () => { const call = mocks.executeSendAction.mock.calls[0]?.[0]; expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:111.222"); }); + + it("matches auto-threading when channel ids differ in case", async () => { + mocks.executeSendAction.mockResolvedValue({ + handledBy: "plugin", + payload: {}, + }); + + await runMessageAction({ + cfg: slackConfig, + action: "send", + params: { + channel: "slack", + target: "channel:c123", + message: "hi", + }, + toolContext: { + currentChannelId: "C123", + currentThreadTs: "333.444", + replyToMode: "all", + }, + agentId: "main", + }); + + const call = mocks.executeSendAction.mock.calls[0]?.[0]; + expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:333.444"); + }); }); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 2a57db17c..50ddce227 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -217,7 +217,7 @@ function resolveSlackAutoThreadId(params: { if (context.replyToMode !== "all" && context.replyToMode !== "first") return undefined; const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); if (!parsedTarget || parsedTarget.kind !== "channel") return undefined; - if (parsedTarget.id !== context.currentChannelId) return undefined; + if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) return undefined; if (context.replyToMode === "first" && context.hasRepliedRef?.value) return undefined; return context.currentThreadTs; }