From 0b2ace898086a701b704bd46b7e2d3eabfff6317 Mon Sep 17 00:00:00 2001 From: Mohith Gajjela <109003762+Mohith26@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:27:27 -0500 Subject: [PATCH 1/2] fix(slack): resolve outgoing mentions on the native streaming path Native streaming appended committed renderer deltas directly as markdown_text, skipping the outgoing @name mention resolution that postMessage and editMessage apply on the post-and-edit fallback. A uniquely cached @name therefore rendered as plain text instead of a Slack mention whenever nativeStreaming (the default) was in effect. Resolve the renderer's committed text incrementally before calculating each delta: line by line, tracking code fence state, so fenced content stays literal and bare mentions reach the resolver whole even when they span source chunks. Ambiguity semantics are unchanged - participant disambiguation still applies and unresolved names stay plain text. Fixes #754 Signed-off-by: Mohith Gajjela <109003762+Mohith26@users.noreply.github.com> --- .changeset/slack-stream-mention-resolution.md | 5 ++ packages/adapter-slack/src/index.ts | 61 +++++++++++++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 .changeset/slack-stream-mention-resolution.md diff --git a/.changeset/slack-stream-mention-resolution.md b/.changeset/slack-stream-mention-resolution.md new file mode 100644 index 000000000..5e027ca30 --- /dev/null +++ b/.changeset/slack-stream-mention-resolution.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/slack": patch +--- + +Resolve outgoing @name mentions on the Slack native streaming path so streamed responses mention users consistently with the post-and-edit fallback. Committed renderer text is resolved incrementally, keeping fenced code literal and preserving the existing ambiguity semantics. diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 530203598..0926e2ff3 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -4653,6 +4653,54 @@ export class SlackAdapter implements Adapter { wrapTablesForAppend: false, }); + // Outgoing @name mention resolution state for the native streaming path. + // The post-and-edit fallback resolves mentions on every postMessage/ + // editMessage call, so the committed renderer text is resolved here too + // before deltas are calculated. `resolvedSourceDone` indexes into the + // renderer's committable text; `resolvedCommitted` is its resolved + // counterpart and the coordinate space `lastAppended` tracks. + let resolvedCommitted = ""; + let resolvedSourceDone = 0; + let insideResolvedFence = false; + + const isFenceLine = (line: string): boolean => { + const trimmed = line.trimStart(); + return trimmed.startsWith("```") || trimmed.startsWith("~~~"); + }; + + /** + * Extend `resolvedCommitted` with newly committed renderer text, applying + * outgoing @name mention resolution. Works line by line, tracking code + * fence state: the renderer only commits partial lines inside fences + * (where mentions stay literal, matching resolveOutgoingMentions) or at + * inline-marker holdback cuts (which never split a bare mention), so + * every bare mention reaches the resolver whole even when it spans + * source chunks. + */ + const resolveCommitted = async (committable: string): Promise => { + while (resolvedSourceDone < committable.length) { + const lineStart = + committable.lastIndexOf("\n", resolvedSourceDone - 1) + 1; + const newlineAt = committable.indexOf("\n", resolvedSourceDone); + const lineEnd = newlineAt === -1 ? committable.length : newlineAt + 1; + const segment = committable.slice(resolvedSourceDone, lineEnd); + const fenceLine = isFenceLine(committable.slice(lineStart, lineEnd)); + if (insideResolvedFence || fenceLine) { + // Fence delimiters and fenced content are literal. + resolvedCommitted += segment; + } else { + resolvedCommitted += await this.resolveOutgoingMentions( + segment, + threadId + ); + } + if (newlineAt !== -1 && fenceLine) { + insideResolvedFence = !insideResolvedFence; + } + resolvedSourceDone = lineEnd; + } + }; + // In-stream fallback state. If the very first native call is rejected // (streaming methods unavailable — e.g. GovSlack — or the feature is off // for the workspace), nothing has rendered yet, so the rest of the stream @@ -4713,17 +4761,18 @@ export class SlackAdapter implements Adapter { }; /** - * Flush committed renderer text: as a markdown_text delta on the native - * stream, or as a throttled post/edit in fallback mode. A failure of the - * FIRST native flush (chat.startStream) switches to fallback mode. + * Flush committed renderer text: as a mention-resolved markdown_text + * delta on the native stream, or as a throttled post/edit in fallback + * mode (postMessage/editMessage resolve mentions themselves). A failure + * of the FIRST native flush (chat.startStream) switches to fallback mode. */ const flushCommitted = async (force = false): Promise => { if (fallback.mode === "fallback") { await flushFallback(force); return; } - const committable = renderer.getCommittableText(); - const delta = committable.slice(lastAppended.length); + await resolveCommitted(renderer.getCommittableText()); + const delta = resolvedCommitted.slice(lastAppended.length); if (delta.length === 0) { return; } @@ -4738,7 +4787,7 @@ export class SlackAdapter implements Adapter { if (response) { fallback.nativeRendered = true; } - lastAppended = committable; + lastAppended = resolvedCommitted; } catch (error) { if (fallback.nativeRendered) { // A native call succeeded earlier; content is already rendering From becc17425cc3cb28993a74799c98333ed4e4d4e1 Mon Sep 17 00:00:00 2001 From: Mohith Gajjela <109003762+Mohith26@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:27:27 -0500 Subject: [PATCH 2/2] test(slack): cover mention resolution on the native streaming path Covers unique-name resolution, mentions spanning source chunks, lines committed mid-stream, ambiguous names staying plain, thread-participant disambiguation, and mentions inside code fences staying literal. Signed-off-by: Mohith Gajjela <109003762+Mohith26@users.noreply.github.com> --- packages/adapter-slack/src/index.test.ts | 122 +++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index a4039e2d4..0e9806eca 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -9194,6 +9194,128 @@ describe("native streaming fallback", () => { }); }); +describe("native streaming outgoing mention resolution", () => { + function createMentionStreamAdapter() { + const state = createMockState(); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: "test-signing-secret", + logger: mockLogger, + }); + (adapter as unknown as { chat: ChatInstance | null }).chat = + createMockChatInstance({ state }); + const append = vi.fn().mockResolvedValue({ ok: true }); + const stop = vi.fn().mockResolvedValue({ + ok: true, + ts: "1234567890.111111", + }); + mockClientMethod( + adapter, + "chatStream", + vi.fn().mockReturnValue({ append, stop }) + ); + return { adapter, append, state }; + } + + function appendedText(append: ReturnType): string { + return append.mock.calls + .map( + (call) => (call[0] as { markdown_text?: string }).markdown_text ?? "" + ) + .join(""); + } + + it("resolves cached @name mentions on the native streaming path", async () => { + const { adapter, append, state } = createMentionStreamAdapter(); + await state.appendToList("slack:user-by-name:alice", "U_ALICE_1"); + + async function* stream() { + yield "Thanks, @alice"; + } + + await adapter.stream("slack:D123:1234567890.000000", stream()); + + expect(appendedText(append)).toBe("Thanks, <@U_ALICE_1>"); + }); + + it("resolves mentions that span source chunks", async () => { + const { adapter, append, state } = createMentionStreamAdapter(); + await state.appendToList("slack:user-by-name:alice", "U_ALICE_1"); + + async function* stream() { + yield "Thanks, @ali"; + yield "ce"; + } + + await adapter.stream("slack:D123:1234567890.000000", stream()); + + expect(appendedText(append)).toBe("Thanks, <@U_ALICE_1>"); + }); + + it("resolves mentions on lines committed mid-stream", async () => { + const { adapter, append, state } = createMentionStreamAdapter(); + await state.appendToList("slack:user-by-name:alice", "U_ALICE_1"); + + async function* stream() { + yield "Hi @alice\nmore "; + yield "text"; + } + + await adapter.stream("slack:D123:1234567890.000000", stream()); + + // The completed line flushes before the stream ends, already resolved. + expect( + (append.mock.calls[0][0] as { markdown_text?: string }).markdown_text + ).toBe("Hi <@U_ALICE_1>\n"); + expect(appendedText(append)).toBe("Hi <@U_ALICE_1>\nmore text"); + }); + + it("leaves ambiguous mentions as plain text", async () => { + const { adapter, append, state } = createMentionStreamAdapter(); + await state.appendToList("slack:user-by-name:alice", "U_ALICE_1"); + await state.appendToList("slack:user-by-name:alice", "U_ALICE_2"); + + async function* stream() { + yield "hey @alice"; + } + + await adapter.stream("slack:D123:1234567890.000000", stream()); + + expect(appendedText(append)).toBe("hey @alice"); + }); + + it("disambiguates ambiguous mentions using thread participants", async () => { + const { adapter, append, state } = createMentionStreamAdapter(); + await state.appendToList("slack:user-by-name:alice", "U_ALICE_1"); + await state.appendToList("slack:user-by-name:alice", "U_ALICE_2"); + await state.appendToList( + "slack:thread-participants:slack:D123:1234567890.000000", + "U_ALICE_2" + ); + + async function* stream() { + yield "hey @alice"; + } + + await adapter.stream("slack:D123:1234567890.000000", stream()); + + expect(appendedText(append)).toBe("hey <@U_ALICE_2>"); + }); + + it("keeps mentions literal inside code fences", async () => { + const { adapter, append, state } = createMentionStreamAdapter(); + await state.appendToList("slack:user-by-name:alice", "U_ALICE_1"); + + async function* stream() { + yield "```\n@alice\n```\nping @alice"; + } + + await adapter.stream("slack:D123:1234567890.000000", stream()); + + expect(appendedText(append)).toBe("```\n@alice\n```\nping <@U_ALICE_1>"); + }); +}); + describe("feedbackButtons", () => { function createStreamAdapter( feedbackButtons?: SlackAdapterConfig["feedbackButtons"]