Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slack-stream-mention-resolution.md
Original file line number Diff line number Diff line change
@@ -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.
122 changes: 122 additions & 0 deletions packages/adapter-slack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>): 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"]
Expand Down
61 changes: 55 additions & 6 deletions packages/adapter-slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4653,6 +4653,54 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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<void> => {
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
Expand Down Expand Up @@ -4713,17 +4761,18 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
};

/**
* 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<void> => {
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;
}
Expand All @@ -4738,7 +4787,7 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
if (response) {
fallback.nativeRendered = true;
}
lastAppended = committable;
lastAppended = resolvedCommitted;
} catch (error) {
if (fallback.nativeRendered) {
// A native call succeeded earlier; content is already rendering
Expand Down