From ee894f349f3ced0e3d9e7e3926477db0edfd3917 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:20:24 +0000 Subject: [PATCH] fix(automations): Bind only dispatch-destination outcome posts to the run An automation run that delivers to another channel, such as the creator's DM, bound that post's thread to the run's Conversation. The Conversation keeps the automation's destination, so every reply in the DM thread failed the destination check and Junior never answered. Fixes JUNIOR-R4 Co-Authored-By: Charly Gomez --- .../junior/src/chat/providers/slack/turn.ts | 19 +++++++---- .../integration/agent-dispatch-work.test.ts | 32 +++++++++++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/junior/src/chat/providers/slack/turn.ts b/packages/junior/src/chat/providers/slack/turn.ts index 35a6c1b784..633bf91015 100644 --- a/packages/junior/src/chat/providers/slack/turn.ts +++ b/packages/junior/src/chat/providers/slack/turn.ts @@ -142,7 +142,7 @@ import { } from "@/chat/conversations/projection"; import { persistWithRetry } from "@/chat/services/persist-retry"; import { trimTrailingAssistantMessages } from "@/chat/pi/transcript"; -import { requireSlackDestination } from "@/chat/destination"; +import { requireSlackDestination, sameDestination } from "@/chat/destination"; import { persistConversationMessages } from "@/chat/conversations/messages"; import { toConversationMessage } from "@/chat/runtime/conversation-message"; import { getTurnLifecycle } from "@/chat/conversations/turn-lifecycle"; @@ -849,12 +849,17 @@ export function createSlackTurn(deps: SlackTurnDeps) { threadTs: outcome.destination.threadTs, }); slackMessageTs.push(...messageIds); - messageDestinations.push( - ...messageIds.map((messageId) => ({ - destination: outcome.destination, - messageId, - })), - ); + // A Conversation keeps one destination. A reply in a thread + // in another channel, such as the creator's DM, starts its + // own Conversation; binding it here would reject that reply. + if (sameDestination(outcome.destination, destination)) { + messageDestinations.push( + ...messageIds.map((messageId) => ({ + destination: outcome.destination, + messageId, + })), + ); + } } } else { slackMessageTs = await sendSlackReply({ diff --git a/packages/junior/tests/integration/agent-dispatch-work.test.ts b/packages/junior/tests/integration/agent-dispatch-work.test.ts index ac6dec8ea2..d8fe2d6d90 100644 --- a/packages/junior/tests/integration/agent-dispatch-work.test.ts +++ b/packages/junior/tests/integration/agent-dispatch-work.test.ts @@ -18,7 +18,11 @@ import { coerceThreadConversationState } from "@/chat/state/conversation"; import { getUserMessageInstructionText } from "@/chat/pi/transcript"; import { getConversationStore } from "@/chat/db"; import { slackApiOutbox } from "../fixtures/slack-api-outbox"; -import { resetSlackApiMockState } from "../msw/handlers/slack-api"; +import { + queueSlackApiResponse, + resetSlackApiMockState, +} from "../msw/handlers/slack-api"; +import { chatPostMessageOk } from "../fixtures/slack/factories/api"; import { createModelStream } from "../fixtures/model-stream"; import { createModelAgentRunner, @@ -271,13 +275,22 @@ describe("agent dispatch conversation work", () => { }, { action: "send_message", - destination: { ...destination, channelId: "C456" }, + destination, }, ], ); const { queue, run, state } = await createConversationWorkHarness( createModelAgentRunner(createModelStream([sendMessage("Work complete")])), ); + queueSlackApiResponse("chat.postMessage", { + body: chatPostMessageOk({ channel: "D123", ts: "1700000000.000401" }), + }); + queueSlackApiResponse("chat.postMessage", { + body: chatPostMessageOk({ + channel: destination.channelId, + ts: "1700000000.000402", + }), + }); await enqueueAgentDispatch(dispatch, { queue, state }); await processConversationQueueMessage(queue.takeMessage(), { @@ -288,7 +301,20 @@ describe("agent dispatch conversation work", () => { expect( slackApiOutbox.messages().map((message) => message.params.channel), - ).toEqual(["D123", "C456"]); + ).toEqual(["D123", destination.channelId]); + // Only the post in the dispatch destination continues the dispatch + // Conversation. A reply in the D123 thread starts its own Conversation. + const binding = (channelId: string, ts: string) => + getConversationStore().getConversationIdByProviderConversation({ + provider: "slack", + providerDestinationId: channelId, + providerTenantId: destination.teamId, + providerConversationId: ts, + }); + await expect(binding("D123", "1700000000.000401")).resolves.toBe(undefined); + await expect( + binding(destination.channelId, "1700000000.000402"), + ).resolves.toBe(getDispatchConversationId(dispatch)); }); it("posts nothing for work with no outcomes, also when the run fails", async () => {