hermes - ✅(Solved) Fix Slack: top-level messages create isolated sessions; sessions.json not persisting [1 pull requests, 2 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#15421Fetched 2026-04-25 06:22:43
View on GitHub
Comments
2
Participants
2
Timeline
7
Reactions
0
Author
Participants
Timeline (top)
labeled ×4commented ×2cross-referenced ×1

Root Cause

Session metadata is supposed to be persisted to disk so that the gateway can reconnect a Slack channel/thread to its existing session after restarts or evictions.

  • File: ~/.hermes/sessions/sessions.json
  • Observed state: Total entries: 0

Despite sessions existing in memory, sessions.json is empty, meaning SessionStore._save() either:

  1. Is never called for Slack sessions,
  2. Fails to serialize Slack session entries, or
  3. Writes to a file that is not being read back on startup.

Because the metadata is lost, the gateway cannot map slack:group:C0AU7N1MMQX:1777065616.184669 (the thread/channel key) back to the prior session ID. Instead it mints a fresh session (20260424_153618_6765bed0), losing all compressed context.

Fix Action

Fixed

PR fix notes

PR #15464: fix(slack): scope top-level channel messages by channel-only when reply_in_thread=false (#15421)

Description (problem / solution / changelog)

What does this PR do?

Fixes `#15421` bug 1: top-level Slack channel messages previously fell back to the message's own `ts` as a synthetic `thread_ts`:

```python thread_ts = event.get("thread_ts") or ts # ts fallback for channels ```

That value flowed into `build_source(thread_id=thread_ts)`. The gateway session store keys sessions by `(platform, channel_id, thread_id)`, so every top-level channel message ended up on a unique session. Operators who set `reply_in_thread: false` in `config.yaml` expected all top-level channel messages to share one session — instead each one spawned a fresh conversation with no context carry-over.

Fix

Three explicit cases in the channel branch:

`event.thread_ts``reply_in_thread``thread_ts` for session keying
non-null (real thread reply)either`event.thread_ts`
null (top-level)true (default)`ts` (legacy: own-thread sessions)
null (top-level)false`None` (shared channel session)

The outbound-reply gate at line 1264 (`reply_to_message_id = thread_ts if thread_ts != ts else None`) already works correctly in all three cases without further changes: `None != ts` is True, so shared-channel top-level messages don't get their reply threaded either — matching the operator's `reply_in_thread=false` intent end-to-end.

Genuine thread replies still scope per-thread under both modes so multi-person threaded conversations can't collide with unrelated channel chatter.

Related Issue

Fixes #15421 bug 1 only. Bug 2 ("sessions.json not persisting across compression") lives elsewhere in the session manager and is left for a separate diff.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✅ Tests (adding or improving test coverage)

Test plan

  • 7 new tests in `tests/gateway/test_slack_channel_session_scope.py` — all green on py3.11 venv
  • Full slack suite — 182 tests pass (175 existing + 7 new, zero regressions)
  • Regression guard verified: reverted the else-branch to the legacy `thread_ts = event.get("thread_ts") or ts` one-liner; `test_top_level_maps_to_none_when_reply_in_thread_false` correctly failed with an assertion message pointing at the regressed invariant. Restored → all 7 pass.
  • Pre-push discipline applied: no bare `except Exception`, no `int()` on user data, no unused imports, no dead helpers (autouse pytest fixture in the file is a false positive on a naive regex scan).

Test coverage detail

All tests drive the real `SlackAdapter._handle_slack_message` code path (not a re-implementation) via the standard pytest fixture pattern used by `tests/gateway/test_slack.py`. Messages @mention the bot so the mention gate doesn't drop them — the tests are specifically about what happens once the handler decides to emit a `MessageEvent`.

`TestChannelSessionScopeDefault` (2 cases — regression guard for the legacy default):

  • `test_top_level_maps_to_ts_when_reply_in_thread_true` — explicit `reply_in_thread: true` keeps `thread_id = ts`
  • `test_top_level_default_behaves_like_true` — unset config behaves like `true` (pins the default)

`TestChannelSessionScopeShared` (3 cases — the #15421 fix):

  • `test_top_level_maps_to_none_when_reply_in_thread_false` — the core fix
  • `test_top_level_reply_to_id_stays_none_when_shared` — outbound doesn't get threaded either
  • `test_thread_reply_scopes_by_thread_even_when_shared` — thread replies still per-thread

`TestThreadReplyAlwaysScopesByThread` (2 parametrised cases):

  • Thread replies get `thread_id = event.thread_ts` regardless of `reply_in_thread` — critical invariant for multi-thread channels

Not in scope

  • #15421 bug 2 (session metadata persistence across compression) — separate area
  • DM session scoping — unchanged; `dm_top_level_threads_as_sessions` already provides the parallel opt-out

Changed files

  • gateway/platforms/slack.py (modified, +32/-1)
  • tests/gateway/test_slack_channel_session_scope.py (added, +259/-0)

Code Example

thread_ts = event.get("thread_ts") or ts

---

Agent cache idle-TTL evict: session=agent:main:slack:group:C0AU7N1MMQX:1777065616.184669 (idle=3719s)
Gateway event (load): session=agent:main:slack:group:C0AU7N1MMQX:1777065616.184669

---

# ~/.hermes/config.yaml
slack:
  require_mention: false
  reply_in_thread: false
  channel_prompts: {}
RAW_BUFFERClick to expand / collapse

Issue: Slack: reply_in_thread: false creates isolated session per top-level message + context lost on compression

Labels: bug, slack, session-management Environment: macOS, Hermes gateway ~/.hermes/hermes-agent/, slack-bolt adapter


Bug 1: Top-level Slack messages always spawn a new thread_id, breaking channel-wide sessions when reply_in_thread: false

Current behavior

When a user sends a top-level message in a Slack channel (not inside an existing thread), the Slack adapter treats that message as its own thread root. Even with reply_in_thread: false in config.yaml, every top-level message gets a unique session key, so channel context never accumulates across messages.

Expected behavior

With reply_in_thread: false, all top-level messages in the same channel should share a single session. Only messages explicitly posted inside an existing thread should spawn a thread-scoped session.

Root cause

In gateway/platforms/slack.py, the ingestion code falls back to the message's own ts when no thread_ts is present:

  • File: gateway/platforms/slack.py
  • Line 1094:
    thread_ts = event.get("thread_ts") or ts

Because event.get("thread_ts") is None for top-level messages, thread_ts becomes the message's own timestamp (ts). This synthetic thread_ts is then passed through to the session manager:

  • Line 1247: build_source(thread_id=thread_ts)
  • Line 1264–1267: MessageEvent created with reply_to_message_id=None (correctly), but source.thread_id=ts (incorrectly for session purposes).

The session store keys sessions by (platform, channel_id, thread_id). Because every top-level message gets a distinct thread_id == ts, each one creates a brand new session instead of reusing the channel session.

Suggested fix

Change the fallback behavior at the ingestion layer so that thread_id is only set to a real Slack thread_ts (i.e., an existing thread root). For a true top-level message, thread_id should remain None, allowing the session store to group it under a single channel-scoped session.

Specifically, at gateway/platforms/slack.py around line 1247, pass the raw event.get("thread_ts") (not the synthetic ts fallback) into build_source(thread_id=...), so that top-level messages produce thread_id=None.

A separate, smaller fix may also be needed in gateway/platforms/slack.py around line 1094 (and the corresponding thread_meta construction in _send_with_retry) to ensure outbound replies are also not threaded when the message was genuinely top-level.


Bug 2: Session metadata is not persisted, causing total context loss on compression or eviction

Current behavior

When the agent's context window fills up and triggers a compression/split event, the gateway correctly creates a new session and (in theory) links it to the prior session's summary. However, after a gateway restart or cache eviction, the session metadata is lost, and the new session starts without any linkage to the previous context. The result is a total context drop.

Evidence from logs

Context was lost during a real conversation on 2026-04-24:

Session IDStarted (UTC)Note
20260424_151838_44cb313f15:18Original session
20260424_152347_79b33615:23First split/compression
20260424_153618_6765bed015:36Erroneous new session with no link to prior summary

Log timestamp: At 15:24:34 the gateway performed a session split/compression. After the split, a later gateway restart (or cache eviction) produced the log line:

Agent cache idle-TTL evict: session=agent:main:slack:group:C0AU7N1MMQX:1777065616.184669 (idle=3719s)
Gateway event (load): session=agent:main:slack:group:C0AU7N1MMQX:1777065616.184669

The new session loaded at 15:36 did not carry forward the compressed context from the original session.

Root cause

Session metadata is supposed to be persisted to disk so that the gateway can reconnect a Slack channel/thread to its existing session after restarts or evictions.

  • File: ~/.hermes/sessions/sessions.json
  • Observed state: Total entries: 0

Despite sessions existing in memory, sessions.json is empty, meaning SessionStore._save() either:

  1. Is never called for Slack sessions,
  2. Fails to serialize Slack session entries, or
  3. Writes to a file that is not being read back on startup.

Because the metadata is lost, the gateway cannot map slack:group:C0AU7N1MMQX:1777065616.184669 (the thread/channel key) back to the prior session ID. Instead it mints a fresh session (20260424_153618_6765bed0), losing all compressed context.

Suggested fix

Investigate gateway/session.py (SessionStore._save() and the Slack-specific session entry creation) to determine why Slack sessions are not being serialized. Verify:

  1. Slack session entries are added to the store's internal dict.
  2. _save() is triggered on session creation/update.
  3. The JSON serialization does not silently drop Slack entries (e.g., due to non-serializable types in the Slack adapter metadata).
  4. On startup, the gateway loads sessions.json and correctly resolves a Slack group:* key to the stored session ID.

Additional context / config

# ~/.hermes/config.yaml
slack:
  require_mention: false
  reply_in_thread: false
  channel_prompts: {}
  • require_mention: false works correctly.
  • reply_in_thread: false is partially respected for outbound reply placement (see _resolve_thread_ts, gateway/platforms/slack.py line 437–442), but the damage is already done because the session key is isolated per message.

Checklist for fix verification

  • Send two top-level messages in the same Slack channel with reply_in_thread: false. Confirm they share the same session ID.
  • Restart the gateway. Confirm the same session ID is restored and context is retained.
  • Start a threaded reply. Confirm it spawns a child session (or thread-scoped session) correctly, without leaking into the main channel session.
  • Confirm sessions.json contains a non-zero number of Slack session entries after normal operation.

extent analysis

TL;DR

Change the fallback behavior in gateway/platforms/slack.py to only set thread_id to a real Slack thread_ts, and investigate why Slack sessions are not being serialized to sessions.json.

Guidance

  • Modify gateway/platforms/slack.py around line 1094 to pass the raw event.get("thread_ts") into build_source(thread_id=...) for top-level messages.
  • Verify that SessionStore._save() is triggered on session creation/update and that Slack session entries are added to the store's internal dict.
  • Check the JSON serialization in SessionStore._save() to ensure it does not silently drop Slack entries.
  • Confirm that the gateway loads sessions.json and correctly resolves a Slack group:* key to the stored session ID on startup.

Example

# Modified code in gateway/platforms/slack.py
thread_ts = event.get("thread_ts")
build_source(thread_id=thread_ts)

Notes

The provided fix suggestions assume that the issue lies in the ingestion layer and session serialization. Further investigation may be needed to confirm the root cause and ensure the suggested fixes are correct.

Recommendation

Apply the workaround by modifying gateway/platforms/slack.py and investigating the session serialization issue, as the problem seems to be related to the current implementation of the Slack adapter and session management.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

With reply_in_thread: false, all top-level messages in the same channel should share a single session. Only messages explicitly posted inside an existing thread should spawn a thread-scoped session.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - ✅(Solved) Fix Slack: top-level messages create isolated sessions; sessions.json not persisting [1 pull requests, 2 comments, 2 participants]