Skip to content

fix(relay): Slack DM-root prompts + flat-DM edit-streaming (native _resolve_thread_ts parity) - #72103

Merged
teknium1 merged 21 commits into
NousResearch:mainfrom
victor-kyriazakos:feat/relay-slack-blockkit-native-parity
Jul 29, 2026
Merged

fix(relay): Slack DM-root prompts + flat-DM edit-streaming (native _resolve_thread_ts parity)#72103
teknium1 merged 21 commits into
NousResearch:mainfrom
victor-kyriazakos:feat/relay-slack-blockkit-native-parity

Conversation

@victor-kyriazakos

@victor-kyriazakos victor-kyriazakos commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What this PR is

Native-parity for Team-Gateway-fronted Slack DMs (the relay lane): streamed replies, rich thinking-status, prompt/approval placement, and session keying all behave exactly as native Slack Hermes. Started as the DM-root streaming fix; grew through a live staging QA campaign (2026-07-26/27, all findings live-verified on a 4-agent fleet) and a review pass (2026-07-28).

Commits (18 on the branch — grouped by concern)

Core streaming fix (original thesis)

  • 15d65da5a stream Slack DM replies flat at DM root — _resolve_reply_to_for_send mirrors native _resolve_thread_ts
  • 08d67792a post clarify/approval prompts at DM root, not in a synthetic thread

QA correctness batch (live-verified on staging)

  • 95103db64 prompts trust the run.py thread stamp (no adapter-side re-derivation)
  • be9de3196 final DM reply honors thread-per-message mode
  • b493bf63c advertise supports_status_text for fronted Slack → run.py feeds live per-tool phrases
  • 467534b43 typing/status synthesizes its thread anchor from the inbound ts
  • a51a17ebe fallback resend promotes surviving reply_to into metadata.thread_id (the connector threads on metadata ONLY)
  • 71d5c47e2 per-message sessions for fronted DMs — native inbound-ts stamp parity (2nd message runs parallel instead of pre-empting)
  • 9864e00fb flat-DM liveliness: status anchors to the triggering ts; replies stay flat (leak-guard tested)
  • 42e4f70ee native approval button labels/styles (Allow Once / Allow Session / Always Allow / Deny)

Refinements

  • 85a75f315 drop the speculative flat_dm_status knob — liveliness is unconditional
  • daefa8c34 contract doc §8: gateway-side platform behavior controls

Review remediation (2026-07-28, addresses @benbarclay)

  • a09015d31 revert the unrelated livetest2 commit (identical fix already on main)
  • 09c4a1d34 remove dead _strip_synthetic_dm_thread; pin the run.py anchor-suppression boundary with tests
  • 3e628edeb replace internal QA-N tracker markers with behavior descriptions
  • 277fc97a0 dm_top_level_threads_as_sessions escape hatch (native session-keying parity)
  • dd866eef3 merge origin/main (per review: merge, not rebase; conflicts resolved semantically)

User-visible surface (new)

Config subset for the relay lane, under platforms.relay.extra.slack (nested wins → legacy flat key fallback → default). Documented in docs/relay-connector-contract.md §8:

Key Default Meaning
reply_in_thread true thread-per-message vs flat rolling DM
dm_top_level_threads_as_sessions true per-message parallel sessions vs one rolling session (mirrors the native knob)

Plus a duck-typed _effective_reply_in_thread() hook consumed by run.py's progress-thread resolver — the threading MODE is decided in exactly one place and expressed to the connector as frame metadata.

Testing

scripts/run_tests.sh tests/gateway/relay/28 files, 306 tests, 0 failed on the merge tip. New coverage includes the run.py↔adapter boundary pins (flat-mode anchor suppression / thread-mode stamp pass-through), session-keying distinctness, the flat-mode leak guard, and the knob default/opt-out contracts. Every behavior change was additionally live-verified on the Coatue staging fleet (Railway, 4 agents + connector).

Supersede note (updated)

The branch originally carried a fan-salvage analysis; that stands. The two revert-shaped commits (e28665837 + a09015d31) cancel to zero net diff — kept as history because this branch is maintained merge-only per review.

@isak-ialogics

Copy link
Copy Markdown
Contributor

CI triage: the attribution failure is branch-specific and actionable. Both PR commits use victor@nousresearch.com, while current main has no contributors/emails/victor@nousresearch.com mapping. The workflow’s expected fix is to add that mapping (for example, python3 scripts/add_contributor.py victor@nousresearch.com victor-kyriazakos) and rerun CI. The separate Windows-footguns failure is the current repo-wide bare write_text issue addressed by #72097.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/slack Slack app adapter area/streaming Streaming responses: gateway delivery, provider wire labels Jul 26, 2026
…thread_ts parity)

On the relay lane a Slack DM's streamed reply was sent with reply_to=the
triggering message ts; the connector maps a raw reply_to to a Slack thread_ts,
so the DM reply posted threaded under the user's message and lost progressive
edit-streaming (flat reply, no thinking status). Native SlackAdapter already
drops that synthetic DM self-anchor when reply_in_thread is off; the relay lane
had no equivalent.

Track chat_type per chat in _capture_scope; add _resolve_reply_to_for_send so a
Slack DM with no real thread_id/thread_ts drops reply_to (and the mirrored
reply_to_message_id) and posts flat at the DM root, edit-streaming its own ts.
Never invents a thread_id; real threads and channel autoThread keep reply_to;
non-DM/non-Slack untouched. Adapted to main's phase-3 prompt architecture.
A prompt (approval/clarify) is emitted in reply to the triggering inbound event,
so its metadata carries that event's synthetic DM thread anchor; forwarded to
the connector it threads the Block Kit prompt under the user's message instead
of posting flat at the DM root. Main routes all prompts through the single
_send_prompt prompt-op choke point, so strip the synthetic DM thread anchor
there via _strip_synthetic_dm_thread — preserving real threads (distinct
thread_id), tenant scope (scope_id/slack_team_id), and non-DM/non-Slack chats.
Preserves main's hp1 prompt-codec; no competing ap:/cl: encoding.
@victor-kyriazakos
victor-kyriazakos force-pushed the feat/relay-slack-blockkit-native-parity branch from 7f552de to 08d6779 Compare July 26, 2026 17:22
…ootgun)

check-windows-footguns (blocking CI) flagged a bare Path.write_text() without
encoding= at scripts/tool_search_livetest2.py:190, which uses the platform
locale encoding on Windows. Pin utf-8. Pre-existing on main; unblocks the
required-checks gate for this PR.
Slack Block Kit buttons only support style primary (green) / danger (red) /
default (white). The relay approval + slash-confirm prompts emitted an invalid
style 'success' (Slack silently drops it → white/stroke button) and baked
emoji into the labels (non-native). Native Slack Hermes uses plain labels with
primary/danger. Map to valid styles (once→primary, deny/cancel→danger,
session/always→default) and drop the emoji from labels. The connector already
compensates success→primary, but emitting valid values at the source is correct
and removes the fragile dependency on that compensation.
@victor-kyriazakos
victor-kyriazakos marked this pull request as draft July 27, 2026 14:32
…-derivation (QA-5)

The threading mode (flat vs thread-per-message) is decided once, in run.py's
_resolve_progress_thread_id (reply_in_thread knob), and encoded in the
metadata stamp: flat => no thread_id, threaded => thread_id for the turn
(first turn: == message_id, the synthetic root IS the thread).

_strip_synthetic_dm_thread re-derived the mode with an unconditional
thread_id == message_id strip, exiling approval/clarify cards (and their
resolved-state swaps) to the DM root while progress bubbles honoured the
thread (2026-07-27 mixed-placement report). Trust the stamp instead; flat
mode is unaffected because flat metadata never carries an anchor.
…reply_to strip on reply_in_thread (QA-6)

_resolve_reply_to_for_send dropped the triggering-ts reply_to on every
Slack DM with no metadata thread_id. But the final-reply lane (platforms/
base.py) builds metadata from source.thread_id only — None for a top-level
DM — so in thread-per-message mode that reply_to is the final reply's ONLY
threading signal, and stripping it exiled the final message to the DM root
while progress bubbles stayed threaded (sibling of the QA-5 prompt bug).

Mirror native _resolve_thread_ts: suppress the synthetic anchor only when
platforms.slack.extra.reply_in_thread=false. Flat mode behavior unchanged;
real threads and channels unchanged.
…s_text, carry live per-tool phrase on typing frames (QA-1)

Native Slack shows dynamic assistant-status text ('Finding answers…',
'is running pytest…') because SlackAdapter sets supports_status_text=True
and renders the set_status_text() phrase in send_typing. The relay lane
advertised nothing, so run.py's live-status lane never fed it phrases and
the connector fell back to the static default.

- supports_status_text: descriptor-gated property (Slack only; other
  fronted platforms keep textless bubbles)
- send_typing: carry the stashed phrase as the typing op's content; omit
  when unset (empty string is Slack's explicit clear, reserved for
  stop_typing). Connector already renders content via
  assistant.threads.setStatus (NousResearch#154).
… the anchor from the inbound ts (QA-1)

Slack's thinking-status line (thread replies footer, plain chat:write —
no assistant scopes needed) is thread-only: the connector's typing case
no-ops without thread_ts. The typing lane's metadata has no anchor for a
top-level DM (base.py builds from source.thread_id = None), so every
status heartbeat was silently dropped — the trace showed typing frames
with meta_keys=['user_id'] only.

Cache the triggering message ts per chat on inbound (_capture_scope) and
synthesize metadata.thread_id on send_typing/stop_typing in
thread-per-message mode, mirroring native send_typing's
_resolve_thread_ts(metadata.message_id). Flat mode unchanged (NousResearch#18859);
real-thread metadata wins over the cache; the clear frame targets the
same synthesized thread so the status never sticks.
…d_id on Slack sends (QA-7)

The connector's Slack sender threads on metadata ONLY: threadTs() reads
metadata.thread_id/thread_ts and never the frame's reply_to. base.py's
final-reply lane (and its stream-fallback 'first response' resend) builds
metadata from source.thread_id — None for a top-level DM — so its sends
carried reply_to as the sole threading signal and posted to the home
channel (2026-07-27 post-approval report; the 15:17:03 frame showed
meta_keys=['notify','user_id']).

After the QA-6 mode gate keeps the anchor, copy it into
metadata.thread_id so the wire carries the signal where the connector
reads it. Flat mode unaffected (anchor already nulled); explicit thread
metadata wins; non-Slack untouched.
@victor-kyriazakos

victor-kyriazakos commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Correctness batch from live QA on the staging deployment (2026-07-27) — five commits, all fixing flat-DM-era assumptions in this PR's own lanes that broke reply_in_thread=true (thread-per-message) deployments:

Commit Fix
95103db64 Prompts trust the run.py thread stamp. _strip_synthetic_dm_thread re-derived the mode via an unconditional thread_id == message_id strip, exiling approval/clarify cards to the DM root while progress stayed threaded. The mode is decided once (run.py _resolve_progress_thread_id); flat metadata never carries an anchor, so the strip is obsolete.
be9de3196 Final DM reply honors the mode. _resolve_reply_to_for_send dropped the triggering-ts reply_to on every DM send; but base.py's final-reply lane has no other threading signal (source.thread_id is None for a top-level DM). Now gated on reply_in_thread, mirroring native _resolve_thread_ts.
b493bf63c Rich status parity. Advertise supports_status_text (descriptor-gated, Slack only) so run.py's live-status lane feeds per-tool phrases; carry the phrase as the typing op's content (connector renders via assistant.threads.setStatus; empty string reserved for the stop_typing clear).
467534b43 Status targets the per-message thread. Slack's status line is thread-only; the typing lane's metadata carries no anchor for a top-level DM, so every heartbeat silently no-op'd connector-side. Cache the inbound ts per chat and synthesize metadata.thread_id on send/stop typing in thread mode.
a51a17ebe Promote the surviving anchor into metadata.thread_id. The connector's Slack sender threads on metadata only (threadTs() never reads the frame's reply_to), so base.py's final/fallback sends still posted to the home channel. Copy the kept anchor into the field the connector reads.

All five verified live on the staging stack (thread-per-message, no-assistant manifest): thinking-status renders, approval card + resolved swap + final reply all land in the per-message thread, flat mode regression-guarded. Relay suite: 288 passed on this branch (mode-aware contracts both directions).

Note: cherry-picked from a staging branch based on current main; the b493bf63c pick was conflict-adjacent to #70717's per-chat descriptor work — resolved to include only this PR's property, no #70717 code rides along.

…bound ts as session thread (QA-3)

A 2nd top-level DM while a turn was in flight resolved to the SAME
session key and steered the running turn ('Redirected current run')
instead of starting its own. Native SlackAdapter stamps thread_ts =
event.thread_ts or ts on EVERY inbound, so build_session_key isolates
each top-level message; the connector normalizes top-level messages with
thread_id=null and the relay lane never reproduced the stamp.

_stamp_slack_session_thread applies native parity on the inbound bridge:
top-level Slack message + thread-per-message mode => source.thread_id =
its own ts (fresh session, parallel turns). Real thread replies and flat
mode untouched (flat keeps the shared rolling DM session on purpose).

Also introduces the enterprise config shape for relay-fronted Slack:
platforms.relay.extra.slack.<subset of native Slack fields> (nested
object wins; legacy flat extra.reply_in_thread still honoured). All
reply_in_thread reads (send/typing/stop_typing/run.py progress) now
route through one resolver.
…, replies stay flat (QA-8)

Victor's correction: flat DMs CAN have a live thinking status. setStatus
on the triggering message's ts renders '… thinking'/per-tool phrases in
that message's thread-footer space and clears without leaving a message
artifact. Native suppresses this because ITS reply routing could inherit
the activated thread; the relay lane's flat-mode sends strip their
anchors explicitly (QA-6/7), so the status anchor cannot leak into reply
placement — proven by the new leak-guard test.

send_typing/stop_typing now anchor the status in flat mode too, gated by
platforms.relay.extra.slack.flat_dm_status (default ON; false restores
the fully anchorless posture). Thread mode unchanged.
@victor-kyriazakos

victor-kyriazakos commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

+2 commits from the final QA round (both live-verified on staging 2026-07-27):

  • 71d5c47e2 QA-3: per-message sessions for fronted Slack DMs. Native inbound stamps thread_ts = event.thread_ts or ts, so every top-level message keys its own session; the connector normalizes top-level thread_id=null and the relay lane collapsed all DMs into one session — a 2nd fast message pre-empted the in-flight turn ("Redirected current run") instead of running parallel. Stamp applied at the inbound bridge (thread-mode only; real threads and flat mode untouched). Also introduces the enterprise config shape platforms.relay.extra.slack.<subset> with one resolver for every reply_in_thread read.
  • 9864e00fb QA-8: flat-DM liveliness. Native's send_typing suppresses the status in flat mode because its reply routing could inherit the setStatus-activated thread. The relay lane strips send anchors explicitly in flat mode (QA-6/7 above), so the contamination can't happen — status anchors to the triggering ts (renders in the footer space, clears with no message artifact) while replies stay flat. Gated flat_dm_status (default on); leak-guard test proves sends stay flat with the anchor active.

Full batch now: QA-5/6/1a/1b/7/3/8 — seven commits, 295 relay tests green, every one verified on the live staging fleet. This completes the native-parity contract in BOTH delivery modes.

@victor-kyriazakos
victor-kyriazakos marked this pull request as ready for review July 27, 2026 23:55
…tional; add relay docs page

flat_dm_status was speculative config (rubric violation): no user wants
'make my agent look dead', and the only real consumer of status
suppression was native's placement-contamination guard — which the relay
lane handles structurally (QA-6/7 send-side anchor strip, leak-guard
test), not via preference. Status now anchors whenever an inbound ts
exists, in both modes.

Docs: new website/docs/user-guide/messaging/relay.md — enterprise-only
relay lane page documenting the platforms.relay.extra.<platform> subset
shape (nested wins, flat fallback), the Slack reply_in_thread control,
and always-on liveliness. Kept out of the native slack.md on purpose:
relay controls are not Slack config.
…tract

Relocate the platforms.relay.extra.<platform> documentation from a new
user-guide page into docs/relay-connector-contract.md (the existing
canonical relay doc, already linked from gateway-internals) as §8. The
relay lane is an enterprise-only component: it gets minor coverage in
the developer-facing contract doc, not a prominent user-guide page, and
no links to private components.
@benbarclay

Copy link
Copy Markdown
Collaborator

Review: changes requested

Posted by Ben's Hermes Agent (automated review on Ben's behalf, using his account).

Reviewed the full diff against origin/main, ran the relay suite locally, and verified the merge/CI state. The Slack DM-root diagnosis is sound and the streaming-path fix (_resolve_reply_to_for_send) is well reasoned — the mode gate mirroring native _resolve_thread_ts is the right shape. But there are four blockers.

1. _strip_synthetic_dm_thread is dead code

Every path through the function returns metadata unmodified. Commit 95103db6 ("prompts trust the run.py thread stamp") removed the actual strip logic but left the function shell, its ~50-line explanatory docblock, and its call site in _send_prompt.

I verified this with a negative control — replaced the entire body with return metadata and re-ran the two new test files:

=== Summary: 2 files, 29 tests passed, 0 failed (100% complete) ===

Zero movement. Which means the four tests named ..._self_anchor_stays_in_thread are asserting a no-op: they'd pass identically if the function were deleted.

Two ways out, either is fine:

  • Delete it — remove the function and the _send_prompt call site, and rewrite those four tests to assert the end-to-end placement contract (prompt metadata arrives from run.py already correct) rather than a helper that does nothing.
  • Restore behavior — if the prompt-at-DM-root bug is genuinely still reachable in flat mode, put the conditional strip back.

Worth flagging either way: flat mode now depends entirely on run.py's _resolve_progress_thread_id suppressing the synthetic anchor upstream. That's a real cross-module coupling, and no test in this PR pins it — if that upstream suppression regresses, the prompt card silently threads again and nothing here catches it. A test at that boundary would be more valuable than the four currently passing on the no-op.

2. The description doesn't match the branch

The body says "it carries only the genuinely still-needed fixes" with a supersede analysis explaining why there are 2 commits. The branch has 13, and seven of them are behavior changes the body never mentions:

Commit Change
b493bf63 advertises supports_status_text for relay-fronted Slack
467534b4 typing/status synthesizes a thread anchor from the inbound ts
71d5c47e _stamp_slack_session_thread — per-message sessions for fronted DMs
a51a17ab promotes surviving reply_to into metadata.thread_id
9864e00f / 85a75f31 flat-DM liveliness; drops the flat_dm_status knob
42e4f70e rewrites approval button labels and styles
daefa8c3 adds a new §8 to the connector contract doc

The new platforms.relay.extra.slack.reply_in_thread config surface and the run.py duck-typed _effective_reply_in_thread hook are both user-visible and both undocumented in the body. Please bring the description in line with what actually shipped.

3. No CI has run on this PR

$ gh pr checks 72103
no checks reported on the 'feat/relay-slack-blockkit-native-parity' branch

mergeable: UNKNOWN, statusCheckRollup: 0. It's a fork PR (victor-kyriazakos/hermes-agent), so workflows need maintainer approval to run — nothing has been validated by the gate.

For what it's worth, locally the suite is green on the branch head via the canonical runner:

$ scripts/run_tests.sh tests/gateway/relay/
=== Summary: 27 files, 295 tests passed, 0 failed in 4.6s ===

That's encouraging but it isn't the gate — this needs a real CI run before it can be assessed.

4. Merge conflicts against current main

git merge-tree reports three conflicting files: gateway/relay/adapter.py, gateway/run.py, and scripts/tool_search_livetest2.py. Please merge latest main into the branch (not rebase).

Also worth addressing

Drop the scripts/tool_search_livetest2.py commit (e28665837). It's unrelated to the Slack relay lane, and the identical fix is already on main — line 190 there already reads out_path.write_text(json.dumps(rec, indent=1), encoding="utf-8"). It contributes nothing but one of the three merge conflicts above.

12 QA-N references in shipped source. gateway/relay/adapter.py carries QA-1, QA-3, QA-5, QA-6, QA-7 markers throughout its comments. These are internal QA-campaign tracker IDs — they mean nothing to a future reader of this file. Describe the behavior instead. (The contract doc and run.py are already clean, so this is just adapter.py.)

Session-keying parity gap worth a second look. _stamp_slack_session_thread makes every top-level relay Slack DM key a fresh session. Native has the same default, but gates it behind an explicit escape hatch:

# plugins/platforms/slack/adapter.py
def _dm_top_level_threads_as_sessions(self) -> bool:
    """... Set platforms.slack.extra.dm_top_level_threads_as_sessions
    to false in config.yaml to revert to the legacy behavior ..."""

The relay version has no equivalent opt-out — it's gated only on reply_in_thread, which also controls reply placement. So an operator who wants threaded replies and one rolling DM session can't express that, whereas on native they can. Since this PR is explicitly framed as native parity, either add the knob or note in the contract doc why the relay lane deliberately couples the two.

Summary

The core streaming fix is good and I'd like to see it land. Blocking on: the dead-code decision (1), a description that matches the branch (2), an actual CI run (3), and a merge from main (4).

@alt-glitch alt-glitch added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades needs-decision Awaiting maintainer decision before any implementation labels Jul 28, 2026
…py anchor-suppression boundary

Review finding (2026-07-28): every path through _strip_synthetic_dm_thread
returned metadata unmodified — the actual strip was removed when prompts
switched to trusting the run.py thread stamp, leaving a 50-line no-op and
four tests that passed against it (verified by reviewer's negative control).

- delete the function + its _send_prompt call site (verbatim pass-through
  with a pointer comment to the single mode authority)
- rewrite the three pass-through tests as end-to-end placement contracts
  (forward run.py's stamp untouched)
- NEW boundary tests pinning run.py._resolve_progress_thread_id itself:
  flat mode suppresses the synthetic self-anchor / preserves real threads;
  thread mode keeps the first-turn self-anchor. This is the cross-module
  coupling the review flagged as unpinned — if the upstream suppression
  regresses, these fail instead of prompts silently threading.
…riptions

Review finding: QA-1/3/5/6/7 are internal campaign tracker ids meaning
nothing to future readers of this file. Comments now describe the behavior
(status thread targeting, metadata-only threading, session-keying parity)
instead of citing the tracker. Comment-only change.
…ession-keying parity

Review finding: native gates per-message DM sessions behind
platforms.slack.extra.dm_top_level_threads_as_sessions; the relay lane
coupled session keying to reply_in_thread alone, so 'threaded replies +
one rolling session' was expressible on native but not here.

Adds the same knob to the relay subset (platforms.relay.extra.slack.
dm_top_level_threads_as_sessions, default true = per-message sessions,
unchanged behavior). false keeps thread-per-message reply placement but
skips the session stamp — one rolling DM session, legacy steer posture.
TDD: opt-out + default-unchanged tests written first.
…kkit-native-parity

# Conflicts:
#	gateway/relay/adapter.py
@victor-kyriazakos

Copy link
Copy Markdown
Contributor Author

@benbarclay All four blockers + the three advisories addressed. Branch is maintained merge-only from here per your ask — today's remediation lands as appended commits (daefa8c34..dd866eef3), no history rewrite.

Finding Resolution Commit
1. Dead _strip_synthetic_dm_thread Deleted (your option 1) — fn + call site gone; the 4 no-op tests rewritten as end-to-end placement contracts. Your deeper point was the real bug: the run.py↔adapter coupling had no pin. New boundary tests now pin _resolve_progress_thread_id itself: flat mode suppresses the synthetic self-anchor / preserves real threads; thread mode passes the first-turn self-anchor through. If the upstream suppression regresses, these fail instead of prompt cards silently threading. 09c4a1d34
2. Description ≠ branch PR body rewritten: all commits grouped by concern, the config surface (reply_in_thread, dm_top_level_threads_as_sessions) and the run.py duck-typed hook documented, supersede note updated. (PR body)
3. No CI Merge from main is in (below) — workflows should be approvable/runnable on the current tip now. Local canonical runner on the merge tip: 28 files, 306 tests, 0 failed. dd866eef3
4. Merge conflicts origin/main merged (not rebased). run.py auto-merged; adapter.py resolved semantically — your Phase-1.5 _descriptor_for_chat block and this PR's supports_status_text property coexist, with the property's docstring now noting the primary-identity convention and deferring per-chat capability decisions to _descriptor_for_chat. livetest2 conflict eliminated by the revert below. dd866eef3
A. livetest2 commit Reverted rather than dropped — identical fix already on main, so revert + main-merge nets to zero diff while keeping the branch merge-only. a09015d31
B. QA-N markers All 12 scrubbed from adapter.py; comments now describe behavior (metadata-only threading, status thread targeting, session-keying parity). 3e628edeb
C. Session-keying opt-out gap Knob added (your first option): platforms.relay.extra.slack.dm_top_level_threads_as_sessions, default true, mirroring the native escape hatch 1:1 — "threaded replies + one rolling session" is now expressible on the relay lane too. TDD'd (opt-out + default-unchanged tests), documented in contract §8. 277fc97a0

One nit from your table for the record: the button-labels commit (42e4f70ee) and the reply_to-promotion commit (a51a17ebe) were in the earlier PR-comment batches, so the description gap was real but those two weren't undocumented — regardless, everything is now in the body.

CI approval is yours when ready — the branch tip is dd866eef3.

@benbarclay

Copy link
Copy Markdown
Collaborator

Re-review: 4 blockers cleared, 3 new findings

Posted by Ben's Hermes Agent (automated review on Ben's behalf, using his account).

Verified every claim against the branch tip (dd866eef3) rather than the summary table. All four blockers are genuinely resolved — and the boundary tests added in response to finding 1 are better than what I asked for. Three new defects, all empirically confirmed with negative-control probes rather than inspection.

Previous blockers — all cleared

# Blocker Status Evidence
1 Dead _strip_synthetic_dm_thread git grep on tip: zero references. Function + call site gone; the four no-op tests rewritten as end-to-end placement contracts. The _resolve_progress_thread_id boundary pins now exist — that was the substantive part of the finding and it landed properly.
2 Description ≠ branch Body lists all 18 commits grouped by concern; both config keys and the run.py duck-typed hook documented.
3 No CI Run 30355933619: all required checks pass, attribution green, Windows-footguns green. mergeStateStatus: CLEAN.
4 Merge conflicts dd866eef3 is a real merge commit from origin/main, not a rebase.

Advisories: A (livetest2) reverted, nets to zero diff. C (session knob) added. B (QA-N scrub) partially done — see finding 3.

Canonical runner re-run on the merge tip in a clean worktree: 28 files, 306 tests passed, 0 failed. Matches the claim.


Finding 1 — the media lane bypasses both threading fixes (blocking)

send() received _resolve_reply_to_for_send and the metadata.thread_id promotion. _send_media() received neither — it passes reply_to straight through to the frame and never touches metadata. That helper backs send_image, send_image_file, send_voice, send_video, and send_document.

Probe output against the real RelayAdapter + StubConnector:

Flat mode:    {'op': 'send_media', 'reply_to': '111.222'}   metadata.thread_id: None
              → image threads UNDER the user's message — the exact bug this PR fixes

Thread mode:  reply_to='111.222'                            metadata.thread_id: None
              → connector's threadTs() never reads reply_to
              → image lands in the home channel, not the per-message thread

Both modes are wrong, in opposite directions — the two failure shapes this PR set out to eliminate on the text lane, still live on the media lane. gateway/run.py calls send_voice/send_document on real delivery paths (lines ~14928, ~15030, ~15048), so this is reachable, not theoretical.

Per the contribution rubric — "fixes the whole bug class, sibling call paths included" — the anchor resolution should move into one shared helper that both send() and _send_media() call, so the two lanes cannot drift again.

Finding 2 — the new knob doesn't coerce like the native one it mirrors

Native:

raw = self.config.extra.get("dm_top_level_threads_as_sessions")
if raw is None:
    return True
return str(raw).strip().lower() in {"1", "true", "yes", "on"}

Relay uses bare bool(). With a YAML-quoted string:

config: dm_top_level_threads_as_sessions: "false"
relay = True      native = False

A PR framed as 1:1 native parity, adding an escape hatch whose entire purpose is being switched off, silently ignores the off switch when the value is a quoted string. _effective_reply_in_thread has the same issue — and that one gates reply placement, session keying, and the run.py progress resolver, so a quoted "false" there fails three ways at once.

Finding 3 — the QA-N scrub only covered adapter.py

Nine markers remain in the two new test files, including the module docstring at test_relay_slack_prompt_dm_root.py:1 and an assertion message at line 373. Same objection as last round: internal QA-campaign tracker IDs mean nothing to a future reader. adapter.py is clean — this is just the tests.


Also worth a look (non-blocking)

stop_typing's anchor guard is asymmetric with send_typing's. send_typing gates on platform == SLACK and chat_type == "dm"; stop_typing gates only on chat_type == "dm". Safe today because of an earlier Slack return, but the two blocks are otherwise copy-paste and will drift the first time either is edited.

Contract doc renumber is clean — old §8 → §9 with no stale cross-references anywhere in docs/ or website/. The new keys live only in the contract doc, not the user-facing site; fine if the relay lane is operator-only, worth a thought if not.


Summary

The core streaming fix is sound and I still want it to land. Blocking only on finding 1 — it's the same bug class on the sibling call path. Findings 2 and 3 are cheap and can ride the same push.

I'm pushing a commit with fixes for all three to save a round-trip; review it as you would any other patch on your branch.

@alt-glitch alt-glitch removed the needs-decision Awaiting maintainer decision before any implementation label Jul 29, 2026
The DM thread-anchor contract was resolved only in send(). _send_media() —
backing send_image, send_image_file, send_voice, send_video and send_document
— passed reply_to straight to the frame and never touched metadata, so
attachments egressing through the same connector-side Slack sender got both
failure shapes this branch set out to remove:

  flat mode   → reply_to survives, the image threads UNDER the user's DM
                message (the original reported symptom)
  thread mode → no metadata.thread_id, and threadTs() never reads reply_to,
                so the image lands in the home channel instead of the
                per-message thread

Both are reachable: gateway/run.py delivers agent artifacts through
send_voice/send_document.

Extract the three steps that must always happen together (mode gate, mirrored
reply_to_message_id strip, metadata promotion) into
_apply_slack_thread_anchor and route BOTH lanes through it, so text and media
cannot drift again. The media lane copies caller metadata rather than mutating
it — these helpers are called in loops with a shared mapping.

Also fold send_typing/stop_typing's duplicated status-anchor blocks into
_with_status_thread_anchor. They had already drifted (stop_typing omitted the
platform check) and the clear must target the thread the heartbeat set or the
status line sticks until Slack's own timeout.

Tests: media lane pinned in both modes plus the channel and
no-caller-mutation cases; verified as real by reverting the fix and watching
them fail.
… does

Both relay Slack knobs read their value through bool(), while the native
adapter they mirror uses str(raw).strip().lower() in {"1","true","yes","on"}.
A YAML-quoted string diverges:

    dm_top_level_threads_as_sessions: "false"   → relay True, native False

Non-empty strings are truthy, so the escape hatch is silently ignored in
exactly the shape an operator writes to switch it OFF. reply_in_thread has the
same defect and gates reply placement, session keying and run.py's progress
resolver, so one quoted "false" misfires three ways.

Route both through a shared _coerce_flag mirroring native's predicate. Real
booleans pass through untouched; None falls back to the default. Contract §8
documents the accepted spellings.

Tests: both knobs parametrized over the true/false spellings native accepts,
plus the absent-key default.
The earlier scrub covered adapter.py; nine internal QA-campaign tracker IDs
remained in the two new test files, including a module docstring and an
assertion message. They mean nothing to a future reader — describe the
behavior instead. Comments only, no assertion changes.
@benbarclay

Copy link
Copy Markdown
Collaborator

Fixes pushed — all three findings addressed

Posted by Ben's Hermes Agent (automated, using Ben's account).

Rather than send this back for another round-trip, I pushed the fixes directly to the branch (maintainerCanModify was on). Three commits appended, no history rewrite — the branch stays merge-only as agreed. Tip is now b521fd9dc.

Commit Finding
33833e232 1 — media lane thread anchor
0dc293f4f 2 — native-parity flag coercion
b521fd9dc 3 — QA-N scrub in the test files

33833e232 — media lane thread anchor

Extracted the three steps that must always happen together — mode gate, mirrored reply_to_message_id strip, and metadata.thread_id promotion — into _apply_slack_thread_anchor, and routed both send() and _send_media() through it. Structural rather than a second copy of the logic, so the two lanes can't drift apart again.

The media lane copies caller metadata instead of mutating in place — these helpers get called in loops with a shared mapping, and the promotion would otherwise leak an anchor into the caller's dict on the next iteration. There's a test pinning that.

Also folded the duplicated status-anchor blocks in send_typing/stop_typing into _with_status_thread_anchor. That's the asymmetry I flagged as non-blocking — the two guards had already drifted (stop_typing was missing the platform check), so one implementation is the fix that keeps them honest.

0dc293f4f — flag coercion

Both knobs now resolve through a shared _coerce_flag mirroring native's str(raw).strip().lower() in {"1","true","yes","on"}. Real booleans pass through untouched, None falls back to the default. Contract §8 documents the accepted spellings so the parity claim is written down, not just implemented.

b521fd9dc — QA-N scrub

Nine markers removed from the two test files. Comments and one assertion message only; no assertion logic touched.


Verification

scripts/run_tests.sh tests/gateway/relay/ on the tip: 28 files, 323 tests, 0 failed (306 → 323, seventeen added).

Each commit is independently green, so the stack stays bisectable:

33833e232  →  311 passed, 0 failed
0dc293f4f  →  323 passed, 0 failed
b521fd9dc  →  323 passed, 0 failed

The new tests were verified to be real, not no-ops. Given last round's finding — four tests passing against a function that did nothing — I reverted each fix and confirmed the tests actually catch the regression:

media lane reverted + bool() coercion restored:
  FAILED test_slack_dm_media_keeps_and_promotes_anchor_in_thread_mode
  FAILED test_slack_dm_media_drops_synthetic_anchor_in_flat_mode
  FAILED test_relay_slack_flags_coerce_like_native[false-False]
  FAILED test_relay_slack_flags_coerce_like_native[False-False1]
  FAILED test_relay_slack_flags_coerce_like_native[ no -False]
  FAILED test_relay_slack_flags_coerce_like_native[off-False]
  FAILED test_relay_slack_flags_coerce_like_native[0-False]
  → 7 failed, 18 passed

ruff check clean on all touched files. The broader tests/gateway/ run has 22 pre-existing local failures in test_complete_path_at_filter.py / test_background_command.py — they fail identically on the unmodified branch tip (filesystem-sensitive locally, green in CI), unrelated to these changes.

One thing I deliberately left alone: test_relay_slack_prompt_dm_root.py has pre-existing ruff format drift at lines ~257 and ~335 that predates my commits. ruff check passes and format isn't enforced by the gate, so I didn't want to bury the review diff in unrelated reformatting. Easy to fold in if you'd prefer it clean.


@victor-kyriazakos — these are on your branch, so please review them as you would any other patch, and push back if you'd have shaped the helper differently. The core diagnosis and streaming fix throughout this PR are yours and they're good work; the media lane was a sibling call path that was easy to miss.

Once CI is green on b521fd9dc this is good to merge from my side.

@alt-glitch alt-glitch added the sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state label Jul 29, 2026
@victor-kyriazakos

Copy link
Copy Markdown
Contributor Author

Thanks so much, both for the fixes and for the general-practices seeds. I reviewed your three commits as requested: re-ran the canonical runner on the tip (323 passed, 0 failed), verified the QA-N grep is clean across the full relay surface, and ran your own mutation trick against you: stubbed _apply_slack_thread_anchor to a pass-through and _coerce_flag back to bool(), 11 tests went red including both media-lane contracts. The shared-helper shape is exactly how I'd have wanted it, and folding stop_typing into _with_status_thread_anchor quietly fixed the guard asymmetry too. LGTM.

The negative-control and sibling-path discipline from your reviews has been absorbed into our dev workflow going forward. Appreciated.

@teknium1
teknium1 merged commit fc551f9 into NousResearch:main Jul 29, 2026
39 checks passed
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…ay-slack-blockkit-native-parity

fix(relay): Slack DM-root prompts + flat-DM edit-streaming (native _resolve_thread_ts parity)
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
…ay-slack-blockkit-native-parity

fix(relay): Slack DM-root prompts + flat-DM edit-streaming (native _resolve_thread_ts parity)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/streaming Streaming responses: gateway delivery, provider wire comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/slack Slack app adapter sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants