Skip to content

[4.41/SL1] Slack outbound mentions: code/URL-aware resolution, native-stream path #206

Description

@patrick-chinchill

Summary

Outgoing Slack text turns @word into mentions in two places: the markdown→Slack payload converter (@name → <@name>) and the adapter's cached-name resolver (@name → <@U…>). Neither pass skips code, and the resolver doesn't skip URLs either. When an agent prints npm i @scope/pkg or `@vercel/postgres`, the snippet is corrupted into a ping. Meanwhile the native streaming path skips name resolution entirely, so a streamed @alice stays plain text while the same text posted normally would ping. This issue moves both passes onto the shared code- and URL-aware scanner from #193, and resolves mentions incrementally on the native stream.

Upstream changes

  • 07c11129 [slack] skip mention resolution inside code blocks (#629) — chat@4.32.0 — resolveOutgoingMentions skips @name inside inline code and fenced blocks.
  • a8c4af74 fix(slack): skip urls during mention resolution (#619) — chat@4.32.0 — replaces the regex passes in resolveOutgoingMentions and SlackFormatConverter (finalize, text nodes) with a char-by-char replaceBareMentions(text, replacer) that skips code, URLs (schemed and schemeless) and <…> tokens.
  • d4c52cad refactor(shared): share the bare-mention scanner across Discord, Teams, and Slack (#652) — chat@4.33.0 — moves the scanner to @chat-adapter/shared. The Python port of the shared helper is [4.41/C2b] Shared text utilities: bare-mention scanner, code-fence helpers, to_plain_text structural whitespace #193; this issue only adopts it in Slack.
  • 6f0d2f02 fix(slack): resolve outgoing mentions on the native streaming path (#755) — chat@4.37.0 — before each append, runs the committed renderer text line by line through resolveOutgoingMentions, tracking fence state (fence lines and fenced content stay literal). Deltas are computed against the resolved buffer.

Current Python behavior

  • src/chat_sdk/adapters/slack/format_converter.py:40 BARE_MENTION_REGEX = (?<![<\w/])@(\w+) (ASCII) and :49-67 _link_bare_mentions_outside_urls skip only http(s):// spans. They are used at :209 (_finalize) and :228 (mrkdwn text nodes). Code spans are not skipped, so install `@vercel/postgres` becomes `<@vercel>/postgres`.
  • src/chat_sdk/adapters/slack/adapter.py:3006-3060 _resolve_outgoing_mentions uses re.compile(r"(?<![\w<])@(\w+)"). It is Unicode \w (no re.ASCII) and skips neither code nor URLs. It is called only via _resolve_message_mentions (:3062-3076) from post/edit/ephemeral/schedule (:3620, :3733, :4079, :4141).
  • adapter.py:3966-3969 flush_markdown_delta appends raw renderer text (streamer.append(markdown_text=delta, ...)). Nothing on the stream path calls _resolve_outgoing_mentions.
  • grep -rn "replace_bare_mentions" src/ finds nothing until [4.41/C2b] Shared text utilities: bare-mention scanner, code-fence helpers, to_plain_text structural whitespace #193 lands.

Scope

  • format_converter.py: delete BARE_MENTION_REGEX, URL_REGEX and _link_bare_mentions_outside_urls. Add _link_bare_mention_names(text) = replace_bare_mentions(text, lambda _m, name: f"<@{name}>") and use it in _finalize and the mrkdwn text-node branch.
  • adapter.py _resolve_outgoing_mentions: collect names with replace_bare_mentions (the replacer returns the mention unchanged). Keep the early return when no names are found. Replace with a second replace_bare_mentions pass that keeps the existing unique / participant-disambiguation / SLACK_USER_ID_EXACT_PATTERN logic.
  • adapter.py stream(): add resolved_committed, resolved_source_done and inside_resolved_fence state plus async def resolve_committed(committable), a line-by-line port of upstream resolveCommitted. Compute every text delta (push_text_and_flush, send_structured_chunk's pre-flush, and the final flush after renderer.finish()) as resolved_committed[len(last_appended):], and set last_appended = resolved_committed.
  • Fence detection on the stream path: a line whose lstrip() starts with ``` or ~~~ toggles fence state, applied only once the line's newline is committed (6f0d2f02 semantics). [4.41/SL3] Slack: rotate long native streams before expiry #208 later replaces this with a marker-matching fence tracker.

Out of scope

Porting notes

  • \w must stay ASCII-only everywhere (upstream JS semantics). If [4.41/C2b] Shared text utilities: bare-mention scanner, code-fence helpers, to_plain_text structural whitespace #193's scanner exposes a name pattern, reuse it; don't reintroduce a Unicode \w regex in the adapter.
  • The resolver is async and runs once per newly committed line, so a long stream issues one state read per line with a bare mention. Keep the early return in _resolve_outgoing_mentions so lines without @ never touch state. Do not cache resolutions across lines: the participant list can change mid-stream.
  • Chunk safety comes from the renderer. It commits partial lines only inside fences (literal) or at inline-marker holdback cuts, which never split a bare @name. Pass the whole line segment to the resolver, never a character-level delta.
  • last_appended must track the resolved buffer. Mixing coordinate spaces duplicates or drops text as soon as a replacement changes length (@alice → <@U123>).
  • Behavior changes to call out:
    1. Streamed @name for a cached user becomes a real ping, which is visible to streaming consumers.
    2. @a/@b now links both mentions. The old / lookbehind skipped @b. See upstream "rewrites slash-separated mentions".
    3. Handles inside inline code and fences are never rewritten.

Tests

None of these files are fidelity-mapped (scripts/verify_test_fidelity.py MAPPING covers packages/chat/src only).

  • packages/adapter-slack/src/index.test.ts → tests/test_slack_api.py (resolver): "skips mentions inside inline code (backticks)", "skips mentions inside code blocks (triple backticks)", "resolves mentions outside code but skips those inside", "handles multiple inline code spans with mentions", "resolves the same name outside code while skipping it inside code", "resolves a mention immediately following an inline code span", "resolves a mention immediately preceding an inline code span", "resolves mentions surrounding a multiline fenced code block", "does not skip a mention after an unbalanced single backtick", "skips a mention inside inline code at the start of the text", "does not resolve @Handles inside URLs".
  • Same file, describe "native streaming outgoing mention resolution": "resolves cached @name mentions on the native streaming path", "resolves mentions that span source chunks", "resolves mentions on lines committed mid-stream", "leaves ambiguous mentions as plain text", "disambiguates ambiguous mentions using thread participants", "keeps mentions literal inside code fences". Use an AsyncMock streamer and assert on the concatenated markdown_text of the append calls.
  • packages/adapter-slack/src/markdown.test.ts → tests/test_slack_format.py: "does not mangle @Handles inside Slack links", "does not mangle @Handles inside Markdown links", "does not link @Handles inside inline code", "does not link @Handles inside fenced code", "rewrites slash-separated mentions", "rewrites mentions after schemeless host punctuation", "preserves schemeless URL handles in response URL markdown", "handles malformed angle text without rescanning it". The last one uses 20,000 < characters; assert the output, not a timing.
  • tests/test_slack_format.py:237-316 already covers the URL cases from a8bf99a. Keep them and delete any that become exact duplicates of the ported names (CLAUDE.md principle 3).
  • Python-specific: a stream whose resolved text grows (@al + ice → <@U1>) produces no duplicated or dropped characters across appends.

Acceptance criteria

  • Full validation command from CLAUDE.md passes.
  • docs/UPSTREAM_SYNC.md updated if anything diverges, such as a Python-only guard in the stream resolver.
  • CHANGELOG entry under "Unreleased (4.41 wave)".
  • Consumer-visible changes called out: code/URL handles are no longer pinged; streamed @name now pings; slash-separated mentions link.
  • Live-loop check: a streamed reply containing `npm i @scope/pkg` and @<cached user> renders the code literally and pings the user.

Dependencies

Blocked by #193 (chat_sdk.shared.mentions.replace_bare_mentions(text, replacer), replacer (mention, name) -> str). Blocks #208 (rotation is written against the resolved buffer). #207 is not blocked but rebases over the resolved-buffer change in stream(); landing SL1 first is simplest.

Metadata

  • Effort: M (~450 LOC incl. tests)
  • Consumer impact: high for streaming consumers. Agent output with package names or shell snippets stops producing spurious pings, and streamed @name starts pinging.
  • Suggested branch: sync/4.41-sl1

Part of #184.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions