Skip to content

Commit 103a99c

Browse files
committed
[chat] fix Slack streaming team ID for interactive payloads
1 parent 4c24c94 commit 103a99c

7 files changed

Lines changed: 461 additions & 23 deletions

File tree

.changeset/beige-forks-judge.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"chat": minor
3+
---
4+
5+
Fix Slack structured streaming when `thread.post(stream)` is called from a handler created by an interactive (`block_actions`) payload.
6+
The team ID is now resolved from `team.id` in addition to `team_id` / `team`.

packages/adapter-slack/README.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -406,10 +406,7 @@ settings:
406406
When streaming in an assistant thread, you can attach Block Kit elements to the final message:
407407

408408
```typescript
409-
const raw = message.raw as { team_id?: string; team?: string };
410-
await thread.adapter.stream(thread.id, textStream, {
411-
recipientUserId: message.author.userId,
412-
recipientTeamId: raw.team_id ?? raw.team,
409+
await thread.post(textStream, {
413410
stopBlocks: [
414411
{ type: "actions", elements: [{ type: "button", text: { type: "plain_text", text: "Retry" }, action_id: "retry" }] },
415412
],

packages/adapter-slack/src/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2296,6 +2296,7 @@ interface MockableClient {
22962296
update: ReturnType<typeof vi.fn>;
22972297
delete: ReturnType<typeof vi.fn>;
22982298
};
2299+
chatStream: ReturnType<typeof vi.fn>;
22992300
conversations: {
23002301
open: ReturnType<typeof vi.fn>;
23012302
replies: ReturnType<typeof vi.fn>;

packages/chat/src/thread.test.ts

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { Card } from "./cards";
3+
import type { Message } from "./message";
34
import {
45
createMockAdapter,
56
createMockState,
@@ -9,7 +10,7 @@ import {
910
import { Plan } from "./plan";
1011
import { StreamingPlan } from "./streaming-plan";
1112
import { ThreadImpl } from "./thread";
12-
import type { Adapter, Message, ScheduledMessage, StreamChunk } from "./types";
13+
import type { Adapter, ScheduledMessage, StreamChunk } from "./types";
1314
import { NotImplementedError } from "./types";
1415

1516
describe("ThreadImpl", () => {
@@ -595,36 +596,56 @@ describe("ThreadImpl", () => {
595596
}
596597
});
597598

598-
it("should pass stream options from current message context", async () => {
599+
it.each([
600+
{
601+
expectedTeamId: "T123",
602+
label: "team_id",
603+
raw: { team_id: "T123", type: "app_mention" },
604+
},
605+
{
606+
expectedTeamId: "T234",
607+
label: "team string",
608+
raw: { team: "T234", type: "message" },
609+
},
610+
{
611+
expectedTeamId: "T345",
612+
label: "team.id",
613+
raw: { team: { id: "T345" }, type: "block_actions" },
614+
},
615+
{
616+
expectedTeamId: "T456",
617+
label: "user.team_id fallback",
618+
raw: {
619+
type: "block_actions",
620+
user: { team_id: "T456" },
621+
},
622+
},
623+
])("should pass stream options from Slack current message context via $label", async ({
624+
raw,
625+
expectedTeamId,
626+
}) => {
599627
const mockStream = vi.fn().mockResolvedValue({
600628
id: "msg-stream",
601629
threadId: "t1",
602630
raw: "Hello",
603631
});
604632
mockAdapter.stream = mockStream;
605633

606-
// Create thread with current message context
607634
const threadWithContext = new ThreadImpl({
608635
id: "slack:C123:1234.5678",
609636
adapter: mockAdapter,
610637
channelId: "C123",
611638
stateAdapter: mockState,
612-
currentMessage: {
613-
id: "original-msg",
614-
threadId: "slack:C123:1234.5678",
615-
text: "test",
616-
formatted: { type: "root", children: [] },
617-
raw: { team_id: "T123" },
639+
currentMessage: createTestMessage("original-msg", "test", {
640+
raw,
618641
author: {
619642
userId: "U456",
620643
userName: "user",
621644
fullName: "Test User",
622645
isBot: false,
623646
isMe: false,
624647
},
625-
metadata: { dateSent: new Date(), edited: false },
626-
attachments: [],
627-
},
648+
}),
628649
});
629650

630651
const textStream = createTextStream(["Hello"]);
@@ -635,11 +656,66 @@ describe("ThreadImpl", () => {
635656
expect.any(Object),
636657
expect.objectContaining({
637658
recipientUserId: "U456",
638-
recipientTeamId: "T123",
659+
recipientTeamId: expectedTeamId,
639660
})
640661
);
641662
});
642663

664+
it("should forward structured stream chunks to adapter.stream from an action-created thread", async () => {
665+
const mockStream = vi.fn().mockResolvedValue({
666+
id: "msg-stream",
667+
threadId: "t1",
668+
raw: "Hello",
669+
});
670+
mockAdapter.stream = mockStream;
671+
672+
const threadWithActionContext = new ThreadImpl({
673+
id: "slack:C123:1234.5678",
674+
adapter: mockAdapter,
675+
channelId: "C123",
676+
stateAdapter: mockState,
677+
currentMessage: createTestMessage("action-msg", "", {
678+
raw: {
679+
team: { domain: "workspace", id: "T123" },
680+
type: "block_actions",
681+
},
682+
author: {
683+
userId: "U456",
684+
userName: "user",
685+
fullName: "Test User",
686+
isBot: false,
687+
isMe: false,
688+
},
689+
}),
690+
});
691+
692+
const taskChunk: StreamChunk = {
693+
id: "task-1",
694+
status: "pending",
695+
title: "Thinking",
696+
type: "task_update",
697+
};
698+
async function* structuredStream(): AsyncIterable<string | StreamChunk> {
699+
yield "Picking option...";
700+
yield taskChunk;
701+
}
702+
703+
await threadWithActionContext.post(
704+
structuredStream() as unknown as AsyncIterable<string>
705+
);
706+
707+
expect(mockStream).toHaveBeenCalledTimes(1);
708+
const [, passedStream] = mockStream.mock.calls[0];
709+
const collected: Array<string | StreamChunk> = [];
710+
for await (const chunk of passedStream as AsyncIterable<
711+
string | StreamChunk
712+
>) {
713+
collected.push(chunk);
714+
}
715+
expect(collected).toContain("Picking option...");
716+
expect(collected).toContainEqual(taskChunk);
717+
});
718+
643719
it("should pass StreamingPlan PostableObject options to adapter.stream", async () => {
644720
const mockStream = vi.fn().mockResolvedValue({
645721
id: "msg-stream",

packages/chat/src/thread.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -574,12 +574,12 @@ export class ThreadImpl<TState = Record<string, unknown>>
574574
const options: StreamOptions = { ...callerOptions };
575575
if (this._currentMessage) {
576576
options.recipientUserId = this._currentMessage.author.userId;
577-
// Extract teamId from raw Slack payload
578-
const raw = this._currentMessage.raw as {
579-
team_id?: string;
580-
team?: string;
581-
};
582-
options.recipientTeamId = raw?.team_id ?? raw?.team;
577+
// recipientTeamId is only consumed by the Slack adapter; other adapters
578+
// ignore it. Derivation is Slack-specific because `currentMessage.raw`
579+
// shape varies across Slack webhook types (message events vs block_actions).
580+
options.recipientTeamId = this.extractSlackRecipientTeamId(
581+
this._currentMessage.raw
582+
);
583583
}
584584

585585
// Use native streaming if adapter supports it
@@ -650,6 +650,47 @@ export class ThreadImpl<TState = Record<string, unknown>>
650650
return this.fallbackStream(textOnlyStream, options);
651651
}
652652

653+
/**
654+
* Slack payloads carry the workspace ID in a few different shapes depending on
655+
* the webhook type:
656+
* - Message events: `team_id` or `team` as a string
657+
* - `block_actions` payloads: `team.id` (object), with `user.team_id` as a fallback
658+
*/
659+
private extractSlackRecipientTeamId(raw: unknown): string | undefined {
660+
if (!raw || typeof raw !== "object") {
661+
return undefined;
662+
}
663+
664+
const payload = raw as {
665+
team?: { id?: unknown } | string;
666+
team_id?: unknown;
667+
user?: { team_id?: unknown };
668+
};
669+
670+
if (typeof payload.team_id === "string" && payload.team_id) {
671+
return payload.team_id;
672+
}
673+
674+
if (typeof payload.team === "string" && payload.team) {
675+
return payload.team;
676+
}
677+
678+
if (
679+
payload.team &&
680+
typeof payload.team === "object" &&
681+
typeof payload.team.id === "string" &&
682+
payload.team.id
683+
) {
684+
return payload.team.id;
685+
}
686+
687+
if (typeof payload.user?.team_id === "string" && payload.user.team_id) {
688+
return payload.user.team_id;
689+
}
690+
691+
return undefined;
692+
}
693+
653694
async startTyping(status?: string): Promise<void> {
654695
await this.adapter.startTyping(this.id, status);
655696
}

0 commit comments

Comments
 (0)