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.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"] 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