Relay-plane parity: hermes config set / Railway persist YAML booleans
as strings, and _slack_unfurl_kwargs silently dropped them — so
'unfurl_links: "false"' was a no-op on native while working on relay.
Coerce recognized string booleans exactly as _slack_unfurl_hints does;
unrecognized values still drop so junk config keeps Slack's default
instead of accidentally suppressing previews.
Replaces test_send_ignores_non_boolean_unfurl_options (which froze the
dropped-string behavior) with coercion + junk-drop tests.
slack_bolt builds a fresh AsyncWebClient for every inbound request and
copies proxy=app.client.proxy into its constructor, where slack_sdk reads
a None/blank proxy *argument* as "unspecified" and reloads HTTP(S)_PROXY
from the environment. aiohttp then treats that env value as an explicit
proxy and skips its own NO_PROXY check, so the adapter's resolved decision
to go direct - a NO_PROXY bypass, or a proxy scheme aiohttp cannot use -
holds on every client except the one authorization spends on auth.test.
The failure looks like a healthy bot: Socket Mode connects, outbound sends
keep working, and every inbound event is rejected with "Failed to authorize
with the given token" - forever, since a failed auth_test_result is not
cached and never retried differently.
Re-apply the resolved proxy through AsyncApp(before_authorize=...), which
bolt inserts before the authorization middleware: the request-scoped client
already exists there and has not been used yet. Assigning the attribute
post-construction is the only way to express "no proxy" to slack_sdk.
Co-authored-by: Junie <junie@jetbrains.com>
Slack sends an authored message twice: flat in `event.text` and structurally
in `event.blocks`. The blocks are rendered so quoted and forwarded content is
not lost, and whatever the render carries beyond the flat text is appended to
the message. That comparison had several ways to fail on the *same* sentence,
each of which showed the author their own words a second time:
1. HTML entities — the flat copy escapes `&`/`<`/`>` while `blocks[].link.url`
stays raw, so any link with query parameters (every "Copy link" on a
thread) mismatched.
2. Permalink unfurls — the live inbound path skips `is_msg_unfurl`
attachments, thread/parent hydration did not, so the linked message's body
was appended again.
3. The Block Kit dump — it serialized the authored `rich_text` alongside the
UI blocks it exists for, and its allowlist drops `url`, so the sentence
reappeared with every link removed.
4. Unknown inline elements — the renderer knew eight types and silently
dropped the rest. A pasted message permalink arrives as `message_mention`,
so the link vanished from the render and the sides stopped comparing equal.
5. `message_mention` without a url — `url` is optional on that element while
`channel_id` and `message_ts` are not, so the element rendered as nothing
and the sentence came back with a blank in the link's place.
6. `date` elements — `fallback` and `url` are both optional, and the flat
`<!date^…>` form was never read down to what the rich text renders.
7. Labelled mentions — Slack may attach a label (`<@U…|name>`,
`<#C…|general>`, `<!subteam^S…|@marketing>`, `<!here|@here>`) in the flat
text while the blocks carry the bare id. The bot's own mention is one of
these, and stripping only its bare form left it in the flat copy.
8. Autolink schemes — only `https` and `mailto` were matched, so a `tel:` link
kept its angle brackets and mismatched too.
Unknown inline types are now read by their `url`/`text`/`fallback` so a type
Slack adds later still renders, and `team`, `color` and a fallback-less `date`
render into the flat form Slack sends. Every field is read as a string or
not at all: Block Kit carries text as an object in many places, and a
non-string one reaches the renderer's `str.join` and raises there, which
costs the whole message. `channel_id` and `message_ts` are the
permalink's own components, so a url-less `message_mention` renders the
permalink's tail; the workspace host and the thread query cannot be rebuilt
from the element, so a permalink on either side is reduced to that same tail.
Canonicalization is used for matching only -- the authored text still reaches
the agent verbatim, so a mistake here can cost an unrendered element, never an
altered or missing message.
An element carrying neither a url nor a label still renders as nothing, and a
message containing one is still appended twice. Suppressing such a render was
tried and is worse: an app message whose body lives only in the blocks
disappears, and a forwarded quote is dropped. Genuinely additional content --
quotes, lists, code blocks, attachments, interactive bot blocks -- is
unaffected throughout.
Tests cover both merge sites (live inbound and thread hydration) and the
negative cases.
* feat(relay): live-card ops — native draft streaming + task cards over the relay (gateway half)
NS-658. Three additive ops within contract v1, emitted only when the
connector's negotiated descriptor advertises them:
{op: draft, chat_id, draft_id, content, final, metadata}
{op: task_card, chat_id, card_id, chunks, metadata}
{op: task_card_stop, chat_id, card_id, metadata}
The gateway side is deliberately dumb: no platform API knowledge, no new
config keys. Slack mechanics (chat.startStream/appendStream/stopStream,
per-workspace feature-gate cache, send+edit fallback) live connector-side
where the platform adapter lives in the relay model.
Semantic bridge: base send_draft is Telegram-shaped (draft clears; final
is a separate send). Slack native streaming makes the stream THE message.
The adapter tracks the open draft per chat and converts the turn-final
send() into draft(final=true) so the connector seals the stream instead
of posting a duplicate; the stream ts returns as the message identity.
A failed frame disarms interception so the edit-based fallback's real
send goes through untouched.
BEHAVIOR CHANGE (deliberate): relay supports_draft_streaming() now
requires the descriptor flag AND the draft op. Flag-only was a latent
lie — send_draft inherited NotImplementedError, so a connector setting
the flag without the op would have crashed the stream consumer's draft
path. supported_ops stays fail-open for legacy (pre-contract) ops;
draft/task_card did not exist pre-contract and must not fail open.
Task cards ride #85476's adapter-agnostic TurnRunner seam (hasattr on
send_native_task_card_progress); supports_native_task_cards() is the
descriptor probe. Connector half + E2E harness pair follow in the gg
repo.
* fix(relay): expose native_task_cards_enabled() on the relay adapter
Live-canary finding (Alice, staging): the TurnRunner's task-card lane
probes adapter.native_task_cards_enabled() (the native Slack adapter's
opt-in contract). The relay adapter only offered
supports_native_task_cards(), so the hasattr gate failed silently and
tool progress stayed on the text path — draft streaming worked, cards
never rendered. Alias it to the descriptor probe.
* fix(relay): match task-card methods to the TurnRunner's native keyword contract
Live-canary finding #2 (Alice, staging): gateway/run.py's card lane calls
send/stop_native_task_card_progress with the NATIVE Slack adapter's
signature (tasks/title/reply_to/metadata/fallback_text, keyword-only) —
PR 85796's relay methods took a positional card_id, so every call raised
TypeError('unexpected keyword argument reply_to') in the progress task,
repeatedly killing the card publisher (and the retry loop resent the
final delivery 4-5x). Card id now derives per turn thread
(turn:<reply_to>), thread_ts anchored like draft; title/fallback_text
accepted for parity, not forwarded (plan-mode stream renders chunks).
* fix(relay): one draft stream per turn for stream-is-the-message adapters
Live-canary finding #4 (Alice, staging): the stream consumer bumps
draft_id at every tool boundary so Telegram-shaped drafts animate each
text segment as a fresh preview. On relay Slack NATIVE streaming a new
draft_id opens a brand-new chat.startStream — the user saw one frozen
message per segment (stuck streaming cursor ▉, never sealed: only the
LAST stream gets the final=true seal) plus the real final; 5-6 cumulative
snapshots per turn. Adapters that mark draft_stream_is_message keep ONE
stream per turn: tool progress lives in the native task card, and the
connector's suffix-delta falls back to whole-text append on prefix
mismatch, so segments append cleanly. Telegram-shaped drafts keep the
per-segment bump.
* fix(relay): don't seal the native stream at tool boundaries — only the turn-final does
Live-canary finding #5 (Alice; supersedes the incomplete #4 which was
necessary but not sufficient). Root cause CONFIRMED by integration trace
(test_live_cards_flow_trace.py, real consumer semantics + real adapter +
stub transport): at every tool boundary the consumer calls
_send_or_edit(finalize=True), which skips the draft path and issues a
real send(); the relay adapter's seal-interception converts THAT into
draft(final=true) — sealing the stream once per segment. Timeline showed
3 seals for a 3-segment turn: exactly the frozen cumulative ▉ snapshots
seen live (the replaced stream never gets stopStream, keeping its cursor).
Fix: for draft_stream_is_message adapters, a segment-break finalize
(finalize=True, is_turn_final=False) stays ON the draft path as another
cumulative frame; only got_done (is_turn_final=True) falls through to
send() and seals. Telegram-shaped platforms unchanged. Trace test now
pins the invariant: ONE user-visible message per turn.
* fix(relay): strip the text cursor from native draft frames
Live-canary finding #6 (Alice) — the ACTUAL duplicate-content mechanism,
confirmed by full-flow scan of both sides' code + logs. The consumer
appends its text cursor (▉) to every non-final display_text tick. The
connector's stream sender diffs CUMULATIVE frames via prefix check:
'abc▉'.startsWith → 'abc def▉' is NEVER a prefix match (the cursor sits
mid-string), so deltaFor falls back to whole-text append on EVERY tick —
chat.appendStream stacks each full cumulative snapshot (cursor included)
into the ONE stream message. Exactly the observed thread: repeated
blocks, each ending in a frozen ▉, growing per tick.
Fixes #4/#5 were real (one stream per turn now) but this was the last
mechanism standing. Native streams render their own typing indicator, so
the text cursor is pure noise on this path: strip it from draft frames.
Prefix check now holds; every tick appends only its true suffix delta.
* fix(relay): seal-interception covers EVERY egress door, not just send()
Live-canary finding #7 (Alice): one duplication remained after #6 — the
stream froze mid-word with the live indicator (never sealed) and the
final posted as a separate message. Log receipt: 'Queued follow-up:
final text delivery confirmed; delivering explicit media before
continuing' — the turn's final went out via the DELIVERY RESOLVER lane
(gateway/delivery.py), which calls send_for_platform() DIRECTLY,
bypassing send() and its seal-interception. The open stream never
absorbed the final; it arrived as a plain 'send' op → chat.postMessage.
Fix: hoist the open-draft check to the top of send() (ahead of the
explicit-platform branch) AND add it to send_for_platform() — an open
native stream absorbs the turn-final regardless of which egress door it
arrives through. The stream IS the message.
* fix(relay): failed seal falls back to plain send (PR 85796 AI-review point 1)
A turn-final seal that fails at the transport must never swallow the
final answer: the stream consumer has already disabled the draft
transport for the run, so a failed _seal_open_draft returning
success=False meant the user got NOTHING. Both seal-interception sites
(send + send_for_platform) now fall through to the regular plain-send
path on seal failure, with a warning receipt. Also mitigates AI-review
point 2 (sticky _open_draft_by_chat after an abandoned turn): a stale
entry's failed seal no longer blocks the next turn's delivery.
* fix(relay): arm seal-interception optimistically; never disarm on ambiguous failure (audit G-D1)
Deep-audit defect G-D1 (HIGH): the outbound leg is at-most-once on the
wire but its ack channel is lossy — send_outbound timeout (30s) and
WS-drop 'failures' frequently mean the frame WAS delivered and the
connector stream is open. send_draft popped _open_draft_by_chat on any
failure, disarming seal-interception while the connector stream lived:
the turn-final went out as a plain send → orphaned mid-word stream +
complete duplicate final (intermittent; needs a drop/timeout inside the
draft window).
Fix: arm the entry BEFORE the transport call and keep it armed on
failure/exception. Safe in every case: sealing a non-existent stream
opens+seals a single complete message connector-side, and a truly failed
seal already falls back to plain send at both interception sites.
Stale-entry damage is self-healing (one warning + plain send).
* fix(relay): gateway-side sealed-draft tombstone — G-D1 arming must not resurrect sealed streams
Regression fix on G-D1 (live: 'worse than before' — escalating frozen
prefixes). Optimistic arming had no seal-awareness: a straggler frame
arriving AFTER the seal re-armed _open_draft_by_chat for the already-
sealed draft_id; the next send was converted to draft(final=true) on the
tombstoned connector key, which CLEARED the connector tombstone (final
frame = new-turn signal), re-opened a stream with cumulative content,
and left it frozen — repeating per straggler: 4-5 escalating frozen
snapshots. Mirror the connector: _sealed_draft_by_chat records the
sealed draft_id per chat (tombstoned BEFORE the seal's transport call);
send_draft for a sealed draft_id is a success no-op (content already in
the sealed message) and never arms. A new turn's fresh draft_id arms
normally.
* fix(relay): key stream/card state per (chat, turn anchor) — parallel turns must not collide (finding #10)
Live finding #10 (Alice; three concurrent turns in one flat DM): all
coordination state was keyed per CHAT on a one-active-turn assumption.
Three parallel turns produced: turn B's task card merged into turn A's
(both were card 'turn:root' — reply_to is None in flat DMs), B left
cardless, and _open/_sealed_draft_by_chat clobbered across writers (3x
duplicate finals on the last turn). Per-turn machinery was correct;
the keys were not.
Fix: _draft_key(chat, metadata) = chat + the turn's thread anchor
(inbound stamps thread_ts = event.thread_ts or ts on every top-level
message, so each turn has one even in flat DMs). draft arming, seal
tombstones, both interception sites, and the task-card id all derive
from the same anchor. New trace test pins two interleaved turns:
distinct cards, own-stream seals, no leaked plain send, no cross-turn
tombstone drops (289 tests green).
* fix(gateway): preserve cumulative native stream across tools
* fix(gateway): consumer-declared final — the seal carries the true final
Three composed fixes for the Slack live-cards duplicate-final class:
1. finish(final_text): TurnRunner passes the completed final_response
(verifier footer, completion explainer included) as the authoritative
finalize payload. The native-stream seal delivers the TRUE final, so
post-stream mutation no longer forks a corrective plain send (#11).
2. Interim-send contract: commentary and segment-tail sends carry a
gateway-internal _interim_send marker; relay seal-interception skips
them at both egress doors. A mid-turn interim send can no longer seal
the live stream and orphan the real final into a duplicate.
3. Queued-follow-up lane reconciles an unconfirmed final by EDITING the
consumer's delivered message in place (sealed stream = regular
message, chat.update live-verified); plain send only as fallback.
This was the actual duplicate lane in the parallel canaries — every
duplicated turn logged 'final stream delivery not confirmed; sending
first response' (subagent-completion queued inbound), not parallelism.
Also: draft frames stay prefix-stable gateway-side (no fence-closing, no
segment state reset, no commentary reset for stream-is-the-message
adapters; MagicMock-safe 'is True' guards).
* test+docs: streaming-contract coverage completeness + maintenance guidelines
Coverage: two gaps closed on the consumer-declared-final contract —
(1) send_for_platform (the delivery-resolver egress door) honors the
_interim_send contract: no seal, marker stripped before the wire;
(2) finish(final_text) on a turn that never streamed does not adopt the
final (delivery ownership stays with the gateway's normal send path for
non-streaming models / tool-only turns).
Docs: AGENTS.md 'Known Pitfalls' gains the streaming delivery contract —
the four invariants of stream-is-the-message adapters (prefix-stable
frames, consumer-declared final, interim-send marker, reconcile-by-edit),
each traced to its live incident, plus the live-probed Slack streaming
API ground truth and the MagicMock 'is True' guard-style note.
* fix(relay): seal transport failure must never silently lose the final (review B1)
Two halves of one silent-loss path, live-probed on the review branch:
1. adapter: _seal_open_draft did not catch transport exceptions. A socket
drop at seal time raised out of send(), skipping the fail-open plain
send entirely. Now: retry the SAME idempotent final frame once (the
connector's sealed-key tombstone returns the original stream ts for a
repeated final — a retry can never open a second stream or duplicate),
then report failure so the caller's fail-open path runs.
2. consumer: the turn-final retry (elif not _already_sent) called
_send_or_edit with finalize=False, which re-entered the DRAFT-FRAME
branch. Its no-op dedupe compared the adopted final against the last
unsealed frame, matched, and returned True with ZERO transport calls —
final_response_sent went green, delivered_final_matches reconciled,
the gateway suppressed its fallback, and the user never received the
answer. finalize=True keeps this retry out of the draft branch.
Regression suite: tests/gateway/test_relay_seal_failure.py (3 tests).
Mutation evidence in follow-up verification: reverting either half sends
the suite red.
* fix(relay): draft ids unique across gateway incarnations (review B3)
The relay connector tombstones sealed streams by (channel, draft_id) and
keeps up to 512 of them; they outlive the gateway process. Relay gateways
are disposable BY DESIGN (scale-to-zero), and _draft_id_counter restarted
at zero every incarnation — so the first turns after every scale-from-zero
in a recently-active channel replayed already-sealed wire identities. The
connector answered those frames straight out of the old tombstone: zero
Slack API calls, the OLD message ts returned as the new turn's identity,
the new answer silently dropped while gateway-side flags recorded success.
Seed the counter from wall-clock milliseconds at process start. Ids stay
plain ints within the existing contract op; incarnations cannot overlap
for realistic turn counts and restart gaps.
Regression: tests/gateway/test_draft_id_restart_uniqueness.py — the seed
test fails on the old code (seed 0 is not epoch-scale).
* fix(relay): stream/card state keyed per TURN, not per thread anchor (review B2)
The thread anchor is the wrong coordination identity — simultaneously:
- too coarse: two parallel turns replying INSIDE ONE Slack thread share
thread_ts. Live-probed on the review branch: turn A's final sealed turn
B's stream with A's content while A's own stream stayed open, and B's
final degraded to a plain send.
- too fragile: a flat DM with no thread metadata degraded to the bare
chat id, re-creating the original finding-#10 collision the anchor was
meant to fix.
_draft_key now prefers the triggering inbound message id (message_id /
reply_to_message_id — per-turn by construction; the gateway's Slack
thread metadata and the consumer's send path both stamp it), falling back
to the thread anchor, then the bare chat. The consumer stamps the same
reply_to_message_id on draft frames so frames and the turn-final resolve
to one key. Task-card ids share the derivation via _card_key (one helper
for send AND stop, so the stop always hits the stream the send opened).
Legacy resolver-lane callers with placement-only metadata still seal via
_match_open_draft's fallback — but ONLY when exactly one stream is open.
With several open, an identity-less send stays a plain send: a duplicate
message is recoverable, sealing someone else's stream is not.
Regression: tests/gateway/relay/test_relay_turn_keying.py (7 tests).
* fix(relay): stream-is-the-message is a Slack semantic, gate it on the descriptor (review B4)
draft_stream_is_message was hardcoded True on the relay adapter class,
i.e. for EVERY relay platform. The base send_draft contract is
Telegram-shaped — the draft clears client-side and the final arrives as
a separate real send that becomes the history message. With the flag
forced on, any non-Slack connector advertising the draft op had its
turn-final intercepted into draft(final=true): probed on the review
branch with a telegram descriptor, the op stream was
[draft(final=false), draft(final=true)] and NO send — no history message
would ever be posted.
Gate the flag on the negotiated descriptor platform (slack), and skip
arming seal-interception entirely when it is off. A future platform with
genuine stream-is-the-message native streaming should advertise it via
the descriptor rather than widening the platform check by guesswork.
Regression: tests/gateway/relay/test_relay_stream_semantics_gating.py
(4 tests: gating both ways, telegram final is a real send, slack final
still seals).
* fix(gateway): mark every mid-turn status lane interim — heartbeats must not seal the stream (review B5)
Seal-interception treats the first unmarked send to an armed (chat, turn)
key as the turn-final. The consumer's own interim lanes (commentary, tail
flush) carry _interim_send, but four gateway-side lanes that fire DURING
a streaming turn did not:
- long-running heartbeat (default every 180s — probed live: at 3 minutes
it sealed the live stream with '⏳ Working — 3 min', the real final
posted as a duplicate, and later frames were silently swallowed by the
seal tombstone)
- inactivity warning
- plain-text approval fallback (button lane failed)
- background-review notice
Add _interim_metadata() beside _non_conversational_metadata and wrap all
four call sites. The marker is gateway-internal; the relay adapter strips
it before the wire (existing behavior, pinned by test).
Note for follow-up: the opt-out shape remains fragile — any FUTURE
unmarked mid-turn send lane re-creates this bug. Inverting the contract
(explicitly mark the one turn-final send) is the durable fix but touches
every adapter's final-delivery path; deliberately kept out of this
review-fix series.
Regression: tests/gateway/test_interim_send_lanes.py (4 tests).
* fix(gateway): interrupted/incomplete turns must not adopt the diagnostic as the stream final (review B6)
The finish(final_text) adoption gate checked only 'not failed', but the
interrupt/abort returns in agent/conversation_loop.py are
{completed: False, interrupted: True, final_response: 'Operation
interrupted during …'} with NO failed key. Adopting that diagnostic:
1. sealed the user's streamed partial answer over with the interrupt
text (stream-is-the-message: the seal rewrites the whole message), and
2. recorded the diagnostic as the turn-final payload, so
delivered_final_matches reconciled and the gateway suppressed its own
error-delivery path — the diagnostic became the ONLY thing delivered.
Enumerated all 27 final_response-bearing return shapes in
conversation_loop.py: every non-happy-path shape carries completed:
False (several with a diagnostic final_response and neither failed nor
interrupted — retry exhaustion, truncation, codex-incomplete); the happy
path routes through turn_finalizer.finalize_turn (completed=True). Gate
is therefore: not failed AND not interrupted AND completed is not False.
Results lacking the completed key entirely (older callers/test doubles)
keep the previous behavior.
Regression: tests/gateway/test_stream_final_adoption_gate.py (6 tests,
incl. a source-level pin on the run.py call site).
* fix(relay): task-card transport failures degrade to failed SendResults (review B7)
send_native_task_card_progress and stop_native_task_card_progress let
transport exceptions escape. The stop runs inside the progress loop's
finally block on the turn-cleanup path, and the post-cancel awaits in
gateway/run.py caught only CancelledError — a socket drop during a card
publish/stop therefore aborted cleanup BEFORE the final-delivery
bookkeeping ran.
Three layers, outermost defends any adapter:
- both adapter methods catch transport exceptions and return failed
SendResults (progress is advisory; the TurnRunner's text fallback
already handles failure results)
- the progress loop's finally wraps the stop (best-effort; the connector
seals orphaned card streams on its own via recycling/eviction)
- the cleanup awaits log-and-continue on non-cancellation errors so
final-delivery bookkeeping always runs
Regression: tests/gateway/relay/test_relay_task_card_failures.py.
* fix(relay): a dying turn seals its native stream instead of orphaning it (review B8)
Stale-generation exits (/new, /stop mid-stream) and cancellations
returned from the consumer's run() with the native stream still open:
- the Slack message kept its live streaming indicator forever (the
cancellation best-effort edit only runs when _message_id exists, and
the native draft path deliberately keeps it None);
- the adapter's armed interception state survived the turn, so the next
turn on the same key could inherit it and seal a dead draft_id.
New adapter op abandon_open_draft(chat, content): seals in place with
the text already on screen (the consumer passes its last delivered
frame) — the seal adds nothing and claims nothing; delivery flags are
never set, so the gateway's normal paths still own whatever happens
next. Best-effort by contract (failure reported, never raised); the
connector reaps truly orphaned streams via recycling/eviction.
The consumer calls it from both death paths: the stale-generation early
return and the CancelledError handler.
Regression: tests/gateway/test_stream_abandon_on_turn_death.py (4 tests,
incl. the next-turn-inheritance hazard).
* fix(relay): bound the draft/seal coordination dicts (review M1)
_sealed_draft_by_chat's key embeds a per-turn identity, so every
completed turn wrote a permanent entry — unbounded growth for the life
of a long-running gateway process (the docstring said 'one entry per
chat', which stopped being true when the key gained the turn anchor).
_open_draft_by_chat could grow the same way via abandoned entries.
FIFO-evict both at 512 entries — the same idiom as the sibling bounded
cache (_auto_thread_by_chat, capped at 256) and the same size as the
connector's own tombstone store. The straggler window the tombstone
exists for is seconds long; FIFO is more than enough.
Regression: tests/gateway/relay/test_relay_state_bounds.py.
* fix(relay): explicit connector rejection disarms interception; exceptions stay armed (review P3)
The G-D1 optimistic-arming change silently dropped disarm-on-failure
entirely: after an EXPLICIT connector rejection (success=False result —
not a transport ambiguity), interception stayed armed even though the
stream consumer disables the draft transport on that failure and falls
back to edit-based streaming. Its turn-final would then be converted
into a seal on a stream the connector just told us is unusable.
test_draft_failure_result_propagates claimed to cover this ('must NOT
leave seal-interception armed') but passed for an unrelated reason: the
stub's canned failure also failed the SEAL, whose fail-open path did the
plain send.
Split the two semantics and pin each honestly:
- explicit rejection (result success=False): disarm — turn-final is a
real send (test_draft_failure_result_propagates, now testing what its
comment says)
- transport exception: ambiguous, stay armed — turn-final still seals
(test_draft_transport_exception_keeps_interception_armed, the G-D1
contract)
Also corrects commit ba3a24a's claim ('a failed frame disarms
interception so the edit-based fallback's real send goes through
untouched') to hold again for the rejection case it described.
* fix(relay): lost acks are ambiguous, not rejections — on the RESULT channel too (review r2, finding 1)
The production ws transport does not raise on ack timeout — it returns
{"success": False, "error": "relay outbound timed out"}. The round-1
ambiguity handling keyed entirely on the exception channel, so the shape
production actually produces was misclassified as a definite connector
rejection. Probed on the head:
- lost SEAL ack: skipped the idempotent retry, fell straight to a plain
send — duplicate final whenever the seal had actually applied;
- lost FRAME ack: the round-1 disarm-on-rejection fired — interception
disarmed, frozen native stream beside a plain final. This re-created
the original G-D1 ambiguous-ack defect on the result channel.
Contract now spans both channels:
- transport: the ack-timeout branch tags ambiguous=True. The fail-fast
branches (closing / not connected) never sent anything and stay
unmarked — they are definite non-delivery.
- adapter frame path: ambiguous results keep interception armed (same
as exceptions); only definite rejections disarm.
- adapter seal path: one shared _attempt() classifier — exception and
ambiguous result both mean "unknown"; the SAME idempotent frame is
retried once (connector tombstone returns the original stream ts for
a repeated final). Only after both attempts stay ambiguous does the
caller's fail-open plain send run: a possible duplicate after double
ack loss beats a silent loss, and double ack loss on one socket
almost always means the transport is down for the plain send too.
Regression: tests/gateway/relay/test_relay_ack_ambiguity.py (6 tests,
incl. a source-of-truth check that the transport tags the timeout branch
and leaves fail-fast branches unmarked).
* fix(relay): stream semantics + draft capability resolve per CHAT, not per primary (review r2, finding 2)
One RelayAdapter fronts N platforms (Phase 1.5): descriptors accumulate
per platform on the transport and egress is tagged per chat — but the
round-1 gate keyed draft_stream_is_message and supports_draft_streaming()
off the PRIMARY scalar descriptor. Probed on the head:
- Slack primary + Telegram chat: the Telegram chat's turn-final was
intercepted into draft(final=true) — no real Telegram history message;
- Telegram primary + Slack chat: the Slack chat was denied native
streaming entirely.
Resolve both through _descriptor_for_chat — the same per-chat machinery
max_message_length already uses (added for the identical class of bug:
the primary's 39000-char cap over-sending into Discord 400s):
- new stream_is_message_for_chat(chat_id) on the adapter; arming and
NotImplementedError gating use it. The class attribute remains as the
single-platform value and legacy-probe fallback.
- supports_draft_streaming() gains an optional chat_id kwarg (base
signature updated; single-platform adapters ignore it). The consumer
passes chat_id with a TypeError fallback for out-of-tree adapters.
- the consumer's four draft_stream_is_message reads collapse into one
_stream_is_message() helper that prefers the per-chat probe
(class-resolved, MagicMock-safe) over the attribute.
Platform-name inference ("slack") stays deliberate: a descriptor-level
semantic field is the right eventual contract but is a cross-repo wire
change — noted for the gg follow-up so future platforms advertise the
semantic explicitly.
Regression: tests/gateway/relay/test_relay_multiplatform_semantics.py
(5 tests: both starvation directions, scalar fallback, per-chat
capability gate).
* fix(gateway): split delivery + authoritative footer reconciles by suffix, not full resend (review r2, finding 3)
The _FINAL_TEXT adoption guard refuses wholesale adoption on split turns
— correct (#78541: sealed heads would repeat inside the tail) but it was
absolute: a post-split verifier footer never entered the ledger,
delivered_final_matches() reported a mismatch, and the gateway resent
the ENTIRE body+footer after the split chunks (the #11 duplicate class,
one level up).
When the authoritative final strictly prefix-extends the split ledger,
the missing suffix is the only undelivered content: append it to the
live tail and the ledger, so the finalize carries it and the recorded
payload reconciles. Non-prefix rewrites keep the full-resend fallback —
a rewrite cannot be patched onto sealed heads.
Regression: tests/gateway/test_split_final_suffix_reconcile.py (3 tests:
suffix rides the tail + reconciles, rewrite still mismatches, unsplit
adoption unchanged).
* fix(relay): cancellation mid-seal restores open state so abandon can close the stream (review r2, finding 4)
_seal_open_draft pops the open entry and writes the local tombstone
BEFORE awaiting transport I/O — correct ordering for the straggler race,
but CancelledError is not an Exception: a cancel during the await
bypassed all failure handling, leaving the remote stream live (visible
streaming indicator until connector eviction) while the local state said
'nothing open'. The consumer's abandon pass — added for exactly this
turn-death case — found nothing to close and no-oped.
On CancelledError: restore the open entry, drop the premature tombstone
(only if it is still ours), re-raise. The abandon path then seals the
stream in place with the on-screen text.
Regression: tests/gateway/relay/test_relay_seal_cancellation.py (2
tests: state restoration, and end-to-end cancel→abandon→remote seal).
* fix(relay): thread anchors are placement, not turn identity — revive the placement-only fallback (review r2, finding 5)
_match_open_draft's single-open-stream fallback was dead for its primary
intended callers: metadata carrying thread_ts/thread_id (placement-only
resolver lanes) was classified as having 'turn identity', so those sends
never reached the fallback — probed: a plain final posted beside the
still-open turn-keyed stream.
Only per-turn MESSAGE ids are identity now. Thread-anchored and bare
callers share the fallback: absorb into the chat's open stream when
EXACTLY one is open; stay a plain send when several are (duplicate is
recoverable, wrong-stream seal is not). Callers WITH a message id whose
key misses never fall back — their identity is authoritative and a miss
means the stream belongs to a different turn.
Regression: 4 new tests in test_relay_turn_keying.py (thread-anchored
seal, both ambiguous-stay-plain shapes, id-mismatch never steals).
* fix(relay): random process nonce for draft-id seeding (review r2, follow-up 6)
The epoch-millisecond seed (round-1 B3 fix) mitigates the restart-replay
class but is not a uniqueness guarantee: two gateways starting in the
same millisecond, a forked process inheriting the class state, or a
clock step backwards can all mint colliding wire identities against the
connector's per-(channel, draft_id) tombstone store.
Seed from secrets.randbits(49) instead: collision probability negligible,
no clock dependence, and ids + realistic per-process turn counts stay
comfortably inside the connector's JS number range (draft_id?: number,
2^53). Regression test now spawns two real interpreters and asserts
their seeds differ — the exact scale-to-zero restart shape, and both
start within the same second so a clock-locked seed would fail it.
* fix(relay): stamp per-turn Slack egress identity — cache is fallback only (R3-5)
The connector (gateway-gateway#210) fills chat.startStream's
recipient_user_id / recipient_team_id — required by Slack when
streaming to a channel — from metadata.user_id / metadata.scope_id.
The gateway stamped only slack_team_id per-turn and left user_id (and
scope_id) to RelayAdapter._with_scope, whose per-chat caches are keyed
on chat_id alone and overwritten by every inbound message: with users
U1 and U2 running overlapping turns in one channel, U2's arrival
overwrote the cache before U1's stream opened, and U1's stream carried
U2 as recipient_user_id.
_thread_metadata_for_source now stamps scope_id and user_id from the
turn's OWN source (setdefault — explicit values win), so identity is
turn-scoped data on the wire. _with_scope is unchanged and fill-only:
the caches keep serving restart/synthetic sends that carry no per-turn
identity, which is all they were ever safe for.
Mutation evidence: reverting the run.py hunk sends
test_thread_metadata_stamps_per_turn_user_and_scope and
test_concurrent_turns_carry_their_own_identity red; restore returns
green. The _with_scope fill-only tests pass on both trees (existing
correct behavior, now pinned against regression).
---------
Co-authored-by: Ben Barclay <ben@nousresearch.com>
Ports the test coverage from PR #29496 onto the salvaged implementation:
adapter-level serialization/workspace isolation/disconnect sealing, and
gateway-level ID-correlated concurrent duplicate tools plus the editable
text fallback when the native stream fails.
Follow-up to PR #70238. Remove 'free-response channel' and
'authorization' jargon from the model-facing prompt. Collapse the
triple-negative 'do not ask / do not reject / do not stay silent'
into a single directive. ~70 tokens vs ~175 in the contributor's
version, same semantics.
Hermes already identifies itself on outbound calls to model providers
and monitoring endpoints — see hermes_cli/models.py and
plugins/model-providers/gmi/__init__.py
("Attribution so GMI can identify traffic from Hermes Agent"). The
Slack adapter is the one outbound surface that doesn't carry the
same identifier, so HermesAgent-driven Slack traffic is
indistinguishable from any other Bolt-Python app at the Slack
platform layer.
This sets `user_agent_prefix=f"HermesAgent/{_HERMES_VERSION}"` on the
AsyncWebClient instances constructed in gateway/platforms/slack.py
and threads them through AsyncApp via its `client` kwarg. Both
kwargs are first-class in slack-sdk and slack-bolt; no new
dependencies. Resulting header looks like:
HermesAgent/<version> Python/3.x slackclient/3.x ...
No behavioral change for users — the Slack API ignores User-Agent
semantically; it lands in logs and analytics. Reversible.
Tests in tests/gateway/test_slack.py:
- TestSlackUserAgent pins the prefix shape and runs connect()
end-to-end (multi-token config) to assert every AsyncWebClient
carries the prefix and AsyncApp receives the pre-built client.
- TestSlackProxyBehavior fakes updated to tolerate the new kwargs
via **_kwargs so they don't break on future passthroughs.
Five regression tests for _resolve_download_token and the download
helpers: explicit team wins, URL-embedded team id routes to the owning
workspace, unknown/no-match fall back to the primary token, and an
end-to-end _download_slack_file_bytes call asserts the Authorization
header carries the owning workspace's token.
A/B: all five fail with the fix commit reverted, pass with it applied.
Two tests on main pinned the pre-#20583 bare-ts marker mechanism
(_mentioned_threads entries and _slash_command_contexts key shape).
The salvaged workspace scoping intentionally changes both:
- _mentioned_threads now records (team_id, ts) markers when the event
carries a team id, so the top-level-mention test asserts the scoped
tuple.
- _slash_command_contexts keys are (team_id, channel, user) 3-tuples
when the slash payload includes team_id.
Follow-up to the #20583 cherry-pick (jordanhubbard).
shivasymbl, z23, Skywind5487, 2001Y, gonzalofrancoceballos, kylezh.
briandevans' noreply mapping already present; mzkarami's mapping file
shipped inside #66204's own commit.
Also fixes the TestFormatMessageTableIntegration fixture to match
PlatformConfig's current signature (no 'name' kwarg).
Slack mrkdwn has no table syntax — GFM pipe tables render as literal-pipe
noise with a raw |---|---| separator row. Wrap detected tables in ```
fences so they render as monospace preformatted text, and pad cells to
per-column max display width (East-Asian Wide / Full-width chars counted
as 2 columns) so columns stay aligned even with CJK content.
Tables already inside fenced code blocks are left untouched, and the
emitted fences carry no language tag so they compose with the
lang-tag-strip pass. This covers the plain-mrkdwn text path; the opt-in
rich_blocks path already renders native Block Kit table blocks.
Reapplied from #16648 by @kylezh — the original patched
gateway/platforms/slack.py, which was migrated to
plugins/platforms/slack/adapter.py in the plugin migration.
Fixes the non-rich_blocks table path described in #8552.
Slack's mrkdwn does not strip the optional language tag from fenced
code blocks like GitHub-flavored markdown does — it renders
```text\nfoo\n``` as a code block whose literal first line is "text".
The agent emitted ```text fences around raw command output, which
surfaced "text" as the first line of every such block.
Drop the tag from the opening fence in format_message() before stashing
the block behind a placeholder. Stripping only fires for a genuine
opening fence — a ``` at the start of a line, tagged with a single
token (no spaces or backticks) — and the original line ending is
preserved. The fence-protection regex deliberately matches loosely, so
a mid-line ``` (e.g. an inline ```span``` wrapping across a newline)
can be grouped as an "opening fence" whose first line is real content;
differential fuzzing against the pre-change formatter (40k generated
messages) confirms the only behavioral delta is the tag strip itself.
The Block Kit renderer is unaffected: render_blocks() intercepts fences
itself before mrkdwn_fn is applied, so this only changes the mrkdwn
surfaces that still go through format_message() — the plain-text
fallback field (notifications, search indexing, accessibility),
slash-command ephemeral replies, and standalone cron delivery.
Originally written against gateway/platforms/slack.py; ported to
plugins/platforms/slack/adapter.py after the adapter migration in
5600105478.
Manually verified against a live Slack workspace (pre-migration
adapter; the ```text case strips identically) — code blocks no longer
carry a literal "text" first line.
format_message unescapes already-escaped input before re-escaping, so that
pre-escaped text doesn't get double-escaped. That unescape was three
sequential str.replace calls, which re-scan each other's output:
"&lt;" --(& -> &)--> "<" --(< -> <)--> "<"
The & produced by the first replace pairs with the following "lt;" and
decodes a second time. "&lt;" is the wire form of the literal text
"<", so the text is silently destroyed: Slack receives "<" and renders
"<". Anyone writing about HTML or markup ("&lt;b&gt;" -> "<b>")
loses their literal text, with no error.
re.sub scans left-to-right and never re-scans its own replacements, so a
single pass fixes it. The escape pass on the next line is left untouched --
it is correctly ordered (& first, so the &s it inserts aren't re-escaped).
Only the double-decode cases change; every other input is byte-identical
before and after. This is the same round-trip invariant the neighbouring
test_pre_escaped_{ampersand,lt,gt}_not_double_escaped tests already assert,
extended to the case they miss. Affects the plain mrkdwn path (send,
edit_message) and Block Kit sections, which route section text through
format_message.
Salvaged from PR #45702 (heartbeat half). A multi-minute turn showed a
static 'is thinking...' assistant status that reads as stuck and provokes
mid-turn 'you there?' pings. Derive the fallback status label from the
turn's elapsed time (>=30s → 'still working… (NmSSs)'), riding the
existing _keep_typing refresh — zero extra API calls.
Ported onto the current plugin adapter: the start time rides the tracked
_active_status_threads entry (workspace-scoped key), so it shares the
existing bounds/eviction and resets when stop_typing clears the status.
Explicit live-status phrases (set_status_text) and configured
typing_status_text always win; only the built-in default label changes.
The PR's other half (top-level channel follow-up coalescing) is NOT
included — dispatch semantics changed on main (busy-input active-turn
redirect, #30170 demotion) and need a fresh design pass.
Refs #45702. Co-authored-by: MrAbsaroka <mrabsaroka@gmail.com>
`SlackAdapter._fetch_thread_context` formats each prior thread message as
`{name}: {msg_text}` and joins them with newlines into the block the call
site prepends *raw* into the model turn (`text = thread_context + text`).
Both fields are attacker-influenceable — any thread participant sets their
own Slack display name and message text — and neither was neutralized, so
an embedded newline let a thread message break out of its line and pose as
a fresh markdown section (a fake "## SYSTEM" / "## Override" heading) inside
the context the model reads when first mentioned mid-thread.
This is the same indirect-prompt-injection vector already closed for the
sibling untrusted sinks: the sender-name prefix
(`neutralize_untrusted_inline_text`), the reply quote, and the relay
channel-context renderer. The Slack thread-context backfill — the default
whenever the bot is mentioned in a thread with no active session — was the
missed sink. (The existing `[unverified]` tagging marks *who* a message is
from; it does nothing about newline structure, so an authorized sender can
inject just as easily.)
Flatten both fields with `neutralize_untrusted_inline_text` before
interpolation. The body uses `max_chars=0` so message text is not truncated
(thread context caps the message *count*, never per-message length); the
display name keeps the default bound. `parent_text` keeps the raw message
(its own reply-context sink neutralizes separately). A well-behaved message
is preserved byte-for-byte.
Adds a regression test covering a hostile display name, a hostile message
body, benign passthrough, and the no-truncation guarantee.
Several platform fetch paths called is_safe_url before constructing ordinary httpx clients, leaving a second DNS lookup at connection time. This preserved the rebinding window for Slack batch images, Feishu documents, Telegram URL-photo fallback, and WeCom remote media.
Route each path through create_ssrf_safe_async_client and the shared redirect guard so direct connections validate and dial vetted IPs while configured proxies remain an explicit trusted egress boundary. Add per-path regressions that change DNS from public at preflight to metadata at connect time.
The Skills Hub provenance fixture intentionally serves content over loopback. Opt that test-scoped server into private-address access so it keeps exercising the real HTTP transport without weakening production blocking.
Related #8033
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
Widening for #24117 ('is thinking...' stuck after the response was
sent): the stuck-thinking class is a missing-cleanup-on-error-path bug.
send()'s status clear was gated on thread_ts already being resolved and
on reaching the normal post-message path, which left the Slack
Assistant status visible when a turn ended through any sibling exit:
- exception BEFORE _resolve_thread_ts (slash-context handling,
formatting, DM resolution) — the 'if thread_ts: stop_typing' clear
never ran
- empty/whitespace-only final response (no_text guard early-return)
- ephemeral slash replies (Slack only auto-clears assistant status on
real thread replies; ephemerals never count)
Add _clear_thread_status_quietly() — a best-effort stop_typing wrapper
that never masks the caller's SendResult — and wire it at every send()
exit plus the finalize paths of edit_message. stop_typing already
handles the untracked-thread fallback (clearing an unset status is a
no-op on Slack's side), so this is pure coverage widening.
Also add the #18859 unit tests for _resolve_progress_thread_id's new
reply_in_thread gate (synthetic-thread drop, real-thread keep, event-id
fallback suppression, default unchanged).
A/B: 3 of the 4 new status-clear tests fail with the adapter widening
reverted and pass with it applied; the fourth pins the existing
cleanup-must-not-mask-result contract.
When reply_in_thread=false, top-level channel events carry their own
message ts as metadata.thread_id for session keying. Calling
assistant.threads.setStatus on that ts activated a Slack assistant
thread ('is thinking...') before the actual response was sent, and the
flat reply then never cleared it.
send_typing now routes through the same _resolve_thread_ts synthetic-
thread guard as message sending, and the gateway threads message_id
through progress/status metadata so the adapter can distinguish real
threads from synthetic top-level session keys.
Reapplied from #18859-sibling PR #17184 by @dorukardahan (both commits:
fix + progress-metadata test) onto current main via 3-way apply — the
original patched gateway/platforms/slack.py, moved to
plugins/platforms/slack/adapter.py in the plugin migration.
Covers cluster C1-images (#69185, #32315, #66136):
- _slack_file_marker unit tests: typed markers per mimetype family,
hostile-filename sanitization (newlines/brackets can't fake context
structure).
- _render_message_text appends markers; a caption-less image post no
longer vanishes from thread context.
- Cold-start hydrate integration: prior-message images surface as
markers in channel_context; the thread root's image is downloaded,
delivered as media_urls, and upgrades message_type to PHOTO.
- Failure path: root-image download failure degrades to the marker,
never blocks the turn.
- Bounds: root delivery capped at _THREAD_ROOT_IMAGE_MAX; non-image
root attachments stay marker-only (no download).
- One-time delivery: active thread session skips the hydrate → no
re-download/re-delivery on later turns.
- Composition: the trigger's own event files still ride alongside a
delivered root image; Slack Connect stubs resolve via files.info;
the collector never issues its own conversations.replies call.
- Delta refresh (#23918 path): images in new replies past the watermark
surface as markers, with no root re-download.
A/B: 14 of 15 tests fail with the adapter fix reverted, all pass with
it applied.
Regression test for the catch-all ack: a re.compile(r'.*') event matcher
must be registered (after every named handler, so it never shadows
message/app_mention/reaction/file routing) and must match unhandled
subscribed event types like member_joined_channel / channel_archive /
pin_added.
Salvaged from #64218 (test half only — its adapter-side catch-all is a
duplicate of #38847, which landed as the base commit of this cluster
with first-submitter credit). Fixes#6572.
Co-authored-by: shivasymbl <sdevinarayanan@asymbl.com>
Post-rebase consolidation over the merged C7/C16/C10 work:
- _ensure_dm_conversation now records workspace ownership via
_remember_channel_team and bounds _dm_conversation_cache (cap 5000)
- module-level _slack_dm_cache (C7 standalone path) bounded oldest-first
- _user_is_bot_cache (C10) bounded with _trim_oldest_dict_entries
- caption-mode contract tests updated for the C7+C8 merged media path
Widen the #19237 send_message fix to the live adapter: send, _upload_file,
send_multiple_images, send_image, send_video, send_document,
send_exec_approval, send_slash_confirm, and send_clarify now route bare
Slack user IDs (U.../W...) through a shared _ensure_dm_conversation helper
before calling chat.postMessage / files_upload_v2, which reject user IDs.
This closes the gap in #17261 where an attachment worked when replying in
a thread but failed when directed at a user DM, and extends the DM-open
fallback to clarify/approval Block Kit prompts so gated actions can reach
a user directly.
Resolution uses the workspace-scoped client (multi-workspace installs open
the DM with the right bot token), caches per (team, user), and records the
opened D... channel in the channel→team map. On failure the original
target passes through so the downstream API call surfaces the real Slack
error.
Fixes#17261
Refs #19236
Follow-up rework on the #51627 cherry-pick:
- Guard the 5th wake check (parent-mentioned-bot, #24848) against a None
parent_text — _fetch_thread_parent_text is typed to return str but tests
(and defensive callers) can surface None; 'in None' raised TypeError.
- Drop the PR's fixture-level _fetch_thread_parent_text/_fetch_thread_context
AsyncMocks from TestThreadReplyHandling/TestAssistantThreadLifecycle: main's
#24848 tests exercise the real parent-text path via conversations_replies
side effects, and the blanket mocks broke them.
- _resolve_user_is_bot reworked to the workspace-scoped (team_id, user_id)
cache key introduced by the multi-workspace name cache on main.
Widening pass over the whole adapter following the cluster-C16 audit
(#51019, #51097, #23676, #23375): every in-memory structure that
accumulates per-message, per-user, or per-thread state is now bounded,
and every eviction is oldest-first — never arbitrary set-iteration
order, which is the #51019 failure mode (bot silently going quiet on
the most ACTIVE thread because set.pop-order eviction removed it).
Newly bounded:
- _approval_resolved / _clarify_resolved (caps 1000): unclicked
approval/clarify prompts leaked their double-click-guard entries
forever; oldest-insertion eviction via _trim_oldest_dict_entries.
- _reacting_message_ids (cap 5000): reaction lifecycle entries leaked
when an exception fired between add and finalize; oldest-ts eviction.
- _active_status_threads (cap 1000): statuses abandoned by error paths
accumulated; oldest-thread-ts eviction so the newest live status is
never cleared.
- _channel_team (cap 10000): grew with every DM channel the bot ever
saw (DM channel IDs are per-user). All four write sites now route
through _remember_channel_team; eviction is safe because entries are
re-learned from the next event and _get_client falls back to the
primary client.
- _slash_command_contexts (cap 1000): TTL cleanup only ran on lookup,
so contexts whose ephemeral replies never happened accumulated;
overflow purges expired entries first, then oldest-stash-first.
Converted from arbitrary set-order eviction to oldest-first:
- _titled_assistant_threads: keys are (team, channel, thread_ts) —
now evicts oldest thread first via _discard_oldest_by_thread_ts.
- _thread_rehydration_checked: keys are team:channel:thread_ts[:user] —
arbitrary eviction here would re-run an ACTIVE thread's restart
rehydration check and re-inject the missed-delta context; now evicts
oldest thread first.
- _reacting_message_ids uses #51097's _discard_oldest_slack_timestamps.
Deliberately NOT bounded (naturally tiny, per-workspace):
_team_clients, _team_bot_user_ids, _team_bot_names (one entry per
installed workspace), _assistant_threads / _agent_view_contexts /
_bot_message_ts / _mentioned_threads / _user_name_cache /
_thread_context_cache (already bounded), _dedup (MessageDeduplicator
has max_size + TTL internally).
New helpers: _trim_oldest_dict_entries (dicts preserve insertion
order, so oldest-first is exact) and _discard_oldest_by_thread_ts
(chronological sort on the embedded Slack ts for keyed sets).
Tests: caps hold under churn, eviction removes OLDEST not arbitrary
entries, newest/active entries survive eviction pressure (regression
shape for #51019), plus end-to-end paths through _resolve_user_name
and _handle_slash_command.
Part of the C16 cache-bounds consolidation with #51097 (markoub),
#23676 and #23375 (EloquentBrush). Fixes#51019.
_fetch_thread_context() caches per-thread Slack history under a
60-second TTL, but expired entries are never removed. On the current
adapter this is worse than when first reported: each entry now also
retains the raw conversations.replies payloads (messages) for
watermark re-formatting, so a busy multi-channel workspace accumulates
full message lists forever. _bot_message_ts, _mentioned_threads, and
_assistant_threads all enforce a MAX constant with active eviction in
the same __init__; _thread_context_cache had the TTL declared but no
matching eviction path.
Fix: add _THREAD_CACHE_MAX = 2500 and purge expired entries
(fetched_at older than _THREAD_CACHE_TTL) whenever a new write pushes
the cache past the limit. TTL-based eviction is used instead of LRU
because fresh entries are still needed; evicting them would
immediately re-trigger a rate-limited conversations.replies call.
Adds two unit tests: one verifying stale entries are purged on
overflow, one verifying fresh entries survive.
Reapplied from #23375 onto the current adapter (moved to
plugins/platforms/slack/adapter.py; cache entries now carry raw
message payloads and parent_user_id).
Cron jobs targeting a Slack user (deliver=slack:U…) failed with
channel_not_found: chat.postMessage and files_upload_v2 require a
conversation ID, and a DM must be opened first via conversations.open
to obtain a D… ID. The tool-level resolution in send_message() does not
cover cron's direct _send_to_platform → standalone_sender_fn path.
Add _resolve_slack_user_dm to the Slack plugin: resolves U…/W… targets
via conversations.open (proxy-aware, cached per token+user so repeated
cron fires don't re-open the DM), wired into _standalone_send ahead of
both the text and media delivery branches so every standalone entry
point benefits.
Adapted from #17444 by @Esther-Zhu023 — the original patched the legacy
_send_slack helper in tools/send_message_tool.py, which has since moved
into the plugin as _standalone_send (#41112).
Closes#17444
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Per review (Victor): response_url failure does not mean ephemeral delivery
is impossible — chat.postEphemeral is an independent API path that keeps
the reply private. Public-channel fallback removed entirely; when both
ephemeral paths fail the reply is dropped with a logged error rather than
leaked to the channel. No config knob needed.
- test_thread_command_skips_context_prefix: post-#69320 thread context IS
fetched on first thread entry but rides channel_context; assert the
command token stays at char zero and the backfill is preserved, instead
of asserting the fetch never happens.
- test_slack_send_retry.py: main's _get_client() now takes team_id;
update the lambda stubs.
Two silent-loss modes in the ephemeral slash reply path:
1. Delivery failure was swallowed: _send_slash_ephemeral returned
success=True on any POST failure, so the user's actual command reply
vanished behind the stale 'Running /cmd…' ack. It now returns
success=False and send() falls back to normal channel delivery.
2. Long replies were truncated to the first ~39k chunk with no notice.
Replies are now chunked across response_url POSTs (first replaces
the ack, follow-ups append, all ephemeral), capped at Slack's 5-POST
response_url budget with an explicit truncation notice when exceeded.
Also updates the #55357 bounded-error-read test for the #26788 precise
context matching (ContextVar must be set) and channel fallback.
Fixes#19688
Commands typed in Slack could be mangled by every enrichment layer the
adapter applies to normal messages:
- Block Kit / unfurl / attachment-notice / text-file injection could
prepend or append content around a command, moving the command token
away from character zero or polluting its arguments. Commands are now
restored from canonical authored input after all enrichment
(final is_command_text guard before MessageEvent construction).
- @bot /cmd (typed slash behind a mention) was never classified as a
command; the mention-strip branch now re-probes for both slash and
bang forms.
- The Slack Agent-view context label ([Slack app context: ...]) was
prepended to command events too; now command-exempt.
- Native slash payload arguments were strip()ed, destroying meaningful
spacing inside/after arguments; only the command delimiter is
nonsemantic now.
- Slash payload thread identity (thread_ts/message_ts, top-level or
nested in message/container) is preserved onto SessionSource so
session-scoped commands hit the same thread session.
- /queue and /steer queued fallbacks now propagate channel_context so a
command that triggered first-entry thread backfill doesn't lose the
history when re-queued.
Adapted from PR #66310 to the post-#69320 channel_context design (thread
history already rides MessageEvent.channel_context, never text).