From fdece8047ec6f3ad1bf512777446d3ecee647d18 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 16 Apr 2026 11:11:32 +0000 Subject: [PATCH] fix: reply in the Slack DM thread the user wrote in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat-sdk-bridge: forward thread.id to the router for DMs so sub-thread context survives into delivery. Previously hardcoded to null, which collapsed every reply to the DM top level. - router: when a DM (is_group=0) is wired as `shared`, don't auto-escalate to per-thread — keep one session for the whole DM and let thread_id flow through to the adapter. - agent-runner poll-loop: defer follow-up messages whose thread_id differs from the active turn's routing. Mixing threads into one streaming turn sent every reply to the first thread because routing is captured at turn start. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/poll-loop.ts | 7 ++++++- src/channels/chat-sdk-bridge.ts | 7 +++++-- src/router.ts | 6 +++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 56e71be..69561e2 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -249,13 +249,18 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config: const pollHandle = setInterval(() => { if (done) return; - // Skip system messages (MCP tool responses) and admin commands (need fresh query) + // Skip system messages (MCP tool responses) and admin commands (need fresh query). + // Also defer messages whose thread_id differs from the active turn's routing + // — mixing threads into one streaming turn would send the reply to the wrong + // thread because `routing` is captured at turn start. The next turn will pick + // them up with fresh routing. const newMessages = getPendingMessages().filter((m) => { if (m.kind === 'system') return false; if (m.kind === 'chat' || m.kind === 'chat-sdk') { const cmd = categorizeMessage(m); if (cmd.category === 'admin') return false; } + if ((m.thread_id ?? null) !== (routing.threadId ?? null)) return false; return true; }); if (newMessages.length > 0) { diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index ef35945..9e06c09 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -175,11 +175,14 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter await thread.subscribe(); }); - // DMs — always forward + subscribe + // DMs — always forward + subscribe. Pass thread.id so sub-thread + // context carries through to delivery (Slack users can open threads + // inside a DM). The router collapses DM sub-threads to one session + // (is_group=0 short-circuits the per-thread escalation). chat.onDirectMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); log.info('Inbound DM received', { adapter: adapter.name, channelId, sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown', threadId: thread.id }); - await setupConfig.onInbound(channelId, null, await messageToInbound(message)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); await thread.subscribe(); }); diff --git a/src/router.ts b/src/router.ts index 23504c9..c705b12 100644 --- a/src/router.ts +++ b/src/router.ts @@ -144,8 +144,12 @@ export async function routeInbound(event: InboundEvent): Promise { // wiring says, because "thread = session" is the first-class model for // threaded platforms. Agent-shared is preserved because it expresses a // cross-channel intent the adapter can't know about. + // + // Exception: DMs (is_group=0). Sub-threads within a DM are a UX affordance, + // not a conversation boundary — treat the whole DM as one session and let + // threadId flow through to delivery so replies land in the right sub-thread. let effectiveSessionMode = match.session_mode; - if (adapter && adapter.supportsThreads && effectiveSessionMode !== 'agent-shared') { + if (adapter && adapter.supportsThreads && effectiveSessionMode !== 'agent-shared' && mg.is_group !== 0) { effectiveSessionMode = 'per-thread'; } const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, effectiveSessionMode);