Summary
On Slack, when an agent posts a reply, the outbound delivery-mirror persists a brand-new session record keyed agent:<id>:slack:channel:<cid>:thread:<ts> for that reply, rather than appending to the conversation's existing thread session. Each reply carries a distinct timestamp, so a steady stream of single-message sessions accumulates: each contains only the bot's outgoing message (a session header plus one assistant delivery-mirror message, with no inbound user turn), or an empty transcript. An active agent accumulates dozens of these near-empty session records.
A secondary observation: the minted mirror entry has no spawnedCwd, so the session transcript header cwd can fall back to process.cwd() (the gateway process working directory) rather than the agent workspace directory.
Environment
- OpenClaw
2026.6.1, host-native Node 24 install, gateway running dist/index.js gateway.
@openclaw/slack channel plugin 2026.6.1.
- Still present on the latest release
v2026.6.10: no functional change to the outbound mirror session path landed in 2026.6.5 through 2026.6.10.
Steps to reproduce
- Configure a Slack channel with an agent that replies in threads (normal threaded behavior).
- Have the agent post several replies on the channel.
- Inspect the agent session store (
sessions.json) and the *.jsonl transcripts.
Expected vs actual
- Expected: an outbound reply in a thread is mirrored into that thread's existing session (
:thread:<rootTs>), so one thread maps to one session.
- Actual: each outbound reply persists a separate session keyed
:thread:<ts> containing only the bot's message, so a single conversation accumulates many assistant-only session records.
Evidence from a live deployment
- Across the fleet, dozens of
:thread:<ts> session entries appear only for Slack-active agents; on one channel a single agent accumulated 21 such entries.
- The large majority are single-message records: a header plus one assistant message tagged
model: "delivery-mirror" with an idempotency key of the form <...>:message-tool:call_<...>, and no inbound user turn; some have an empty transcript.
- The suffixes are distinct, tightly spaced timestamps (seconds apart) within a continuous exchange. One minted session's outbound reply timestamp was observed becoming the
:thread: suffix of the next minted session.
- One minted key carried
thread:/dev/null as its thread id, indicating a non-timestamp value reached the session-key suffix without validation.
Pointers for triage (verified present in the deployed build, not a claimed root cause)
Filenames are build-hashed esbuild chunk names; line numbers are from the deployed 2026.6.1 build.
- The outbound delivery-mirror is rebuilt and persisted on every send:
prepareOutboundMirrorRoute calls ensureOutboundSessionEntry and writes the mirror under the resolved route's session key (dist/message-action-runner-<hash>.js, around lines 565-590).
ensureOutboundSessionEntry calls recordSessionMetaFromInbound with no createIfMissing argument (dist/outbound-session-<hash>.js, around lines 122-141), and that defaults createIfMissing = params.createIfMissing ?? true (dist/store-<hash>.js:1254; the only skip is if (!existing && !createIfMissing) at line 1283), so a route key with no existing entry mints a new session.
- The thread-suffixed key is built by
resolveThreadSessionKeys as `${baseSessionKey}:thread:${normalizedThread}` (dist/session-key-<hash>.js, around lines 182-192).
- The cwd fallback: the transcript header sets
cwd: params.cwd ?? process.cwd() (dist/transcript-<hash>.js, around line 142); the mirror append passes cwd: entry.spawnedCwd (around line 745), which is absent on the mirror entry.
Related (not duplicate)
- #80091 (Slack DM delivery mirrors split sessions; its fix PR #80111 was closed without merging). Same delivery-mirror routing area, but that report is a DM peer mis-classification.
- #78505 (outbound thread reply does not promote the session key; closed). Closest conceptual match.
- #87019 (Slack DM session fan-out; open) and umbrella #69208 (cross-channel transcript and session duplication).
Summary
On Slack, when an agent posts a reply, the outbound delivery-mirror persists a brand-new session record keyed
agent:<id>:slack:channel:<cid>:thread:<ts>for that reply, rather than appending to the conversation's existing thread session. Each reply carries a distinct timestamp, so a steady stream of single-message sessions accumulates: each contains only the bot's outgoing message (a session header plus one assistantdelivery-mirrormessage, with no inbound user turn), or an empty transcript. An active agent accumulates dozens of these near-empty session records.A secondary observation: the minted mirror entry has no
spawnedCwd, so the session transcript headercwdcan fall back toprocess.cwd()(the gateway process working directory) rather than the agent workspace directory.Environment
2026.6.1, host-native Node 24 install, gateway runningdist/index.js gateway.@openclaw/slackchannel plugin2026.6.1.v2026.6.10: no functional change to the outbound mirror session path landed in2026.6.5through2026.6.10.Steps to reproduce
sessions.json) and the*.jsonltranscripts.Expected vs actual
:thread:<rootTs>), so one thread maps to one session.:thread:<ts>containing only the bot's message, so a single conversation accumulates many assistant-only session records.Evidence from a live deployment
:thread:<ts>session entries appear only for Slack-active agents; on one channel a single agent accumulated 21 such entries.model: "delivery-mirror"with an idempotency key of the form<...>:message-tool:call_<...>, and no inbound user turn; some have an empty transcript.:thread:suffix of the next minted session.thread:/dev/nullas its thread id, indicating a non-timestamp value reached the session-key suffix without validation.Pointers for triage (verified present in the deployed build, not a claimed root cause)
Filenames are build-hashed esbuild chunk names; line numbers are from the deployed
2026.6.1build.prepareOutboundMirrorRoutecallsensureOutboundSessionEntryand writes the mirror under the resolved route's session key (dist/message-action-runner-<hash>.js, around lines 565-590).ensureOutboundSessionEntrycallsrecordSessionMetaFromInboundwith nocreateIfMissingargument (dist/outbound-session-<hash>.js, around lines 122-141), and that defaultscreateIfMissing = params.createIfMissing ?? true(dist/store-<hash>.js:1254; the only skip isif (!existing && !createIfMissing)at line 1283), so a route key with no existing entry mints a new session.resolveThreadSessionKeysas`${baseSessionKey}:thread:${normalizedThread}`(dist/session-key-<hash>.js, around lines 182-192).cwd: params.cwd ?? process.cwd()(dist/transcript-<hash>.js, around line 142); the mirror append passescwd: entry.spawnedCwd(around line 745), which is absent on the mirror entry.Related (not duplicate)