Skip to content

feat(router): per-channel threadId rewrite for per-thread sessions#2471

Open
luisfontes wants to merge 1 commit into
nanocoai:mainfrom
luxurypresence:upstream-pr/per-thread-threadid-rewrite
Open

feat(router): per-channel threadId rewrite for per-thread sessions#2471
luisfontes wants to merge 1 commit into
nanocoai:mainfrom
luxurypresence:upstream-pr/per-thread-threadid-rewrite

Conversation

@luisfontes
Copy link
Copy Markdown

@luisfontes luisfontes commented May 14, 2026

TL;DR

No user-facing change in this PR. Adds an optional adapter hook that the router applies only in session_mode=per-thread, so channel adapters can fix platforms (Slack DMs) where the SDK assigns the same threadId to every top-level message and per-thread consequently collapses to one session. Slack consumes it in #2472.

Summary

Adds an optional rewriteThreadIdForSession hook on the ChannelAdapter interface. The router calls it inside deliverToAgent only when the wiring's effective session_mode is per-thread, applying it to both the session key and the inbound's deliveryAddr.threadId so inbound / session / outbound all agree on the same thread id.

Why

Some platforms encode top-level (un-threaded) messages with an ambiguous "empty thread" placeholder that collapses every top-level post to the same threadId. A concrete example, which motivated this PR: in Slack, a top-level DM event arrives with thread_ts="", so every unsolicited DM gets the same threadId. A wiring with session_mode=per-thread on that DM degenerates to one ever-growing session, and outbound replies post as flat channel messages instead of in-thread (because postMessage reads thread_ts from the threadId, and empty means "post at top level").

The adapter knows the platform's encoding and can mint a per-message id; the router stays adapter-agnostic.

Why opt-in, and why only in per-thread mode

  • The hook is optional on the interface — adapters that don't implement it get today's behavior.
  • The router only consults it when effectiveSessionMode === 'per-thread'. In shared / agent-shared mode the threadId doesn't gate the session, so a rewrite wouldn't help and isn't called.
  • INVARIANT documented on the hook: channelIdFromThreadId(rewritten) must equal channelIdFromThreadId(original). The router doesn't re-derive channelId after rewrite, so the adapter is responsible for preserving it.

Result: this is byte-identical for every existing install. Operators who explicitly opt into per-thread DM sessions get correct behavior — no env var, no new config; just follows from the existing session_mode knob.

Files

  • src/channels/adapter.ts — adds rewriteThreadIdForSession?(threadId, messageId) to ChannelAdapter, with INVARIANT note.
  • src/router.ts — computes effectiveThreadId and threads it into resolveSession and deliveryAddr.

Test plan

  • Host typecheck + tests pass (pnpm run build, pnpm test).
  • No adapter implements rewriteThreadIdForSession in this PR → behavior unchanged for all current channels.
  • Companion PR feat(slack): per-message thread for top-level posts in per-thread sessions #2472 against the channels branch wires the Slack adapter to this hook. Verified locally by combining both patches: top-level Slack DM under session_mode=per-thread produces a fresh session per message; outbound reply posts in-thread.

🤖 Generated with Claude Code

Add an optional `rewriteThreadIdForSession` hook on the ChannelAdapter
interface. The router calls it inside `deliverToAgent` only when the
wiring's effective session_mode is per-thread, applying it to both
the session key and the inbound's deliveryAddr threadId so inbound /
session / outbound all agree on the same id.

Why opt-in via this hook: some platforms encode top-level (un-threaded)
messages with an ambiguous "empty thread" placeholder. A Slack DM event
without a pre-existing thread arrives with `thread_ts=""`, so every
top-level DM collapses to one threadId and `session_mode=per-thread`
on a DM wiring degenerates to a single ever-growing session. The
adapter that knows the platform's encoding can mint a per-message id;
the router stays adapter-agnostic.

The hook is consulted ONLY in per-thread mode because shared / agent-
shared sessions don't gate on threadId — rewriting in those modes
would just point outbound replies somewhere different without
changing routing.

INVARIANT documented on the hook: `channelIdFromThreadId(rewritten)`
must equal `channelIdFromThreadId(original)`. The router doesn't
re-derive channelId after rewrite; the adapter must preserve it.

Slack uses this in a follow-up PR against the `channels` branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant