Skip to content

Commit 3deb644

Browse files
committed
Expose stream options on thread.post
1 parent f7cc3fa commit 3deb644

7 files changed

Lines changed: 129 additions & 31 deletions

File tree

apps/docs/content/docs/api/thread.mdx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,14 @@ await thread.post(Card({ title: "Hi", children: [Text("Hello")] }));
5656

5757
// Stream (fullStream recommended for multi-step agents)
5858
await thread.post(result.fullStream);
59+
60+
// Stream with Slack-specific display options
61+
await thread.post(result.fullStream, {
62+
stream: { taskDisplayMode: "plan" },
63+
});
5964
```
6065

61-
**Parameters:** `message: string | PostableMessage | CardJSXElement`
66+
**Parameters:** `message: string | PostableMessage | CardJSXElement`, `options?: PostOptions`
6267

6368
**Returns:** `Promise<SentMessage>` — the sent message with `edit()`, `delete()`, `addReaction()`, and `removeReaction()` methods.
6469

apps/docs/content/docs/posting-messages.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ const result = await agent.stream({ prompt: message.text });
149149
await thread.post(result.fullStream);
150150
```
151151

152-
Both `fullStream` and `textStream` are supported. Use `fullStream` with multi-step agents — it preserves paragraph breaks between steps. Any `AsyncIterable<string>` also works for custom streams.
152+
Both `fullStream` and `textStream` are supported. Use `fullStream` with multi-step agents — it preserves paragraph breaks between steps. Any `AsyncIterable<string>` also works for custom streams. For Slack-specific streaming controls like `taskDisplayMode` or `stopBlocks`, pass a second argument: `await thread.post(result.fullStream, { stream: { taskDisplayMode: "plan" } })`.
153153

154154
See the [Streaming](/docs/streaming) page for details on platform behavior and configuration.
155155

apps/docs/content/docs/streaming.mdx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,10 @@ await thread.post(stream);
150150
Control how `task_update` chunks render in Slack by passing `taskDisplayMode` in stream options:
151151

152152
```typescript
153-
await thread.stream(stream, {
154-
taskDisplayMode: "plan", // Group all tasks into a single plan block
153+
await thread.post(stream, {
154+
stream: {
155+
taskDisplayMode: "plan", // Group all tasks into a single plan block
156+
},
155157
});
156158
```
157159

@@ -167,17 +169,19 @@ Adapters without structured chunk support extract text from `markdown_text` chun
167169
When streaming in Slack, you can attach Block Kit elements to the final message using `stopBlocks`. This is useful for adding action buttons after a streamed response completes:
168170

169171
```typescript title="lib/bot.ts" lineNumbers
170-
await thread.stream(textStream, {
171-
stopBlocks: [
172-
{
173-
type: "actions",
174-
elements: [{
175-
type: "button",
176-
text: { type: "plain_text", text: "Retry" },
177-
action_id: "retry",
178-
}],
179-
},
180-
],
172+
await thread.post(textStream, {
173+
stream: {
174+
stopBlocks: [
175+
{
176+
type: "actions",
177+
elements: [{
178+
type: "button",
179+
text: { type: "plain_text", text: "Retry" },
180+
action_id: "retry",
181+
}],
182+
},
183+
],
184+
},
181185
});
182186
```
183187

packages/chat/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,13 +306,15 @@ export type {
306306
ModalSubmitHandler,
307307
ModalUpdateResponse,
308308
PlanUpdateChunk,
309+
PostOptions,
309310
Postable,
310311
PostableAst,
311312
PostableCard,
312313
PostableMarkdown,
313314
PostableMessage,
314315
PostableRaw,
315316
PostEphemeralOptions,
317+
PostStreamOptions,
316318
RawMessage,
317319
ReactionEvent,
318320
ReactionHandler,

packages/chat/src/thread.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,69 @@ describe("ThreadImpl", () => {
575575
})
576576
);
577577
});
578+
579+
it("should pass public stream options through thread.post", async () => {
580+
const mockStream = vi.fn().mockResolvedValue({
581+
id: "msg-stream",
582+
threadId: "t1",
583+
raw: "Hello",
584+
});
585+
mockAdapter.stream = mockStream;
586+
587+
const textStream = createTextStream(["Hello"]);
588+
await thread.post(textStream, {
589+
stream: {
590+
stopBlocks: [{ type: "actions" }],
591+
taskDisplayMode: "plan",
592+
},
593+
});
594+
595+
expect(mockStream).toHaveBeenCalledWith(
596+
"slack:C123:1234.5678",
597+
expect.any(Object),
598+
expect.objectContaining({
599+
stopBlocks: [{ type: "actions" }],
600+
taskDisplayMode: "plan",
601+
})
602+
);
603+
});
604+
605+
it("should ignore stream options for non-stream posts", async () => {
606+
await thread.post("Hello world", {
607+
stream: {
608+
stopBlocks: [{ type: "actions" }],
609+
taskDisplayMode: "plan",
610+
},
611+
});
612+
613+
expect(mockAdapter.postMessage).toHaveBeenCalledWith(
614+
"slack:C123:1234.5678",
615+
"Hello world"
616+
);
617+
expect(mockAdapter.stream).toBeUndefined();
618+
});
619+
620+
it("should preserve fallback streaming behavior when public stream options are provided", async () => {
621+
mockAdapter.stream = undefined;
622+
623+
const textStream = createTextStream(["Hello", " ", "World"]);
624+
await thread.post(textStream, {
625+
stream: {
626+
stopBlocks: [{ type: "actions" }],
627+
taskDisplayMode: "plan",
628+
},
629+
});
630+
631+
expect(mockAdapter.postMessage).toHaveBeenCalledWith(
632+
"slack:C123:1234.5678",
633+
"..."
634+
);
635+
expect(mockAdapter.editMessage).toHaveBeenLastCalledWith(
636+
"slack:C123:1234.5678",
637+
"msg-1",
638+
{ markdown: "Hello World" }
639+
);
640+
});
578641
});
579642

580643
describe("fallback streaming error logging", () => {

packages/chat/src/thread.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import type {
2323
Author,
2424
Channel,
2525
EphemeralMessage,
26+
PostOptions,
2627
PostableMessage,
2728
PostEphemeralOptions,
29+
PostStreamOptions,
2830
ScheduledMessage,
2931
SentMessage,
3032
StateAdapter,
@@ -105,8 +107,7 @@ function isAsyncIterable(
105107
}
106108

107109
export class ThreadImpl<TState = Record<string, unknown>>
108-
implements Thread<TState>
109-
{
110+
implements Thread<TState> {
110111
readonly id: string;
111112
readonly channelId: string;
112113
readonly isDM: boolean;
@@ -368,11 +369,12 @@ export class ThreadImpl<TState = Record<string, unknown>>
368369
}
369370

370371
async post(
371-
message: string | PostableMessage | ChatElement
372+
message: string | PostableMessage | ChatElement,
373+
options?: PostOptions
372374
): Promise<SentMessage> {
373375
// Handle AsyncIterable (streaming)
374376
if (isAsyncIterable(message)) {
375-
return this.handleStream(message);
377+
return this.handleStream(message, options?.stream);
376378
}
377379

378380
// After filtering out streams, we have an AdapterPostableMessage
@@ -484,12 +486,13 @@ export class ThreadImpl<TState = Record<string, unknown>>
484486
* then uses adapter's native streaming if available, otherwise falls back to post+edit.
485487
*/
486488
private async handleStream(
487-
rawStream: AsyncIterable<string | StreamChunk | StreamEvent>
489+
rawStream: AsyncIterable<string | StreamChunk | StreamEvent>,
490+
streamOptions?: PostStreamOptions
488491
): Promise<SentMessage> {
489492
// Normalize: handles plain strings, AI SDK fullStream events, and StreamChunk objects
490493
const textStream = fromFullStream(rawStream);
491494
// Build streaming options from current message context
492-
const options: StreamOptions = {};
495+
const options: StreamOptions = { ...streamOptions };
493496
if (this._currentMessage) {
494497
options.recipientUserId = this._currentMessage.author.userId;
495498
// Extract teamId from raw Slack payload

packages/chat/src/types.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export {
2121
} from "./errors";
2222
export type { Logger, LogLevel } from "./logger";
2323
export { ConsoleLogger } from "./logger";
24+
export { Message } from "./message";
2425

2526
// =============================================================================
2627
// Configuration
@@ -76,12 +77,12 @@ export interface ChatConfig<
7677
* the token no longer matches.
7778
*/
7879
onLockConflict?:
79-
| "force"
80-
| "drop"
81-
| ((
82-
threadId: string,
83-
message: Message
84-
) => "force" | "drop" | Promise<"force" | "drop">);
80+
| "force"
81+
| "drop"
82+
| ((
83+
threadId: string,
84+
message: Message
85+
) => "force" | "drop" | Promise<"force" | "drop">);
8586
/** State adapter for subscriptions and locking */
8687
state: StateAdapter;
8788
/**
@@ -428,6 +429,22 @@ export interface StreamOptions {
428429
updateIntervalMs?: number;
429430
}
430431

432+
/**
433+
* Public streaming options supported via `thread.post(stream, { stream: ... })`.
434+
* Internal routing fields like recipient context remain adapter-managed.
435+
*/
436+
export type PostStreamOptions = Omit<StreamOptions, "recipientUserId" | "recipientTeamId">;
437+
438+
/**
439+
* Optional settings for `post()`.
440+
*
441+
* Streaming options are only applied when `message` is an async iterable.
442+
*/
443+
export interface PostOptions {
444+
/** Options forwarded to native streaming adapters for async iterable messages. */
445+
stream?: PostStreamOptions;
446+
}
447+
431448
/** Internal interface for Chat instance passed to adapters */
432449
export interface ChatInstance {
433450
/** Get the configured logger, optionally with a child prefix */
@@ -654,7 +671,8 @@ export interface Postable<
654671
* Post a message.
655672
*/
656673
post(
657-
message: string | PostableMessage | ChatElement
674+
message: string | PostableMessage | ChatElement,
675+
options?: PostOptions
658676
): Promise<SentMessage<TRawMessage>>;
659677

660678
/**
@@ -865,11 +883,14 @@ export interface Thread<TState = Record<string, unknown>, TRawMessage = unknown>
865883
*
866884
* // Stream from AI SDK
867885
* const result = await agent.stream({ prompt: message.text });
868-
* await thread.post(result.textStream);
886+
* await thread.post(result.textStream, {
887+
* stream: { taskDisplayMode: "plan" },
888+
* });
869889
* ```
870890
*/
871891
post(
872-
message: string | PostableMessage | ChatElement
892+
message: string | PostableMessage | ChatElement,
893+
options?: PostOptions
873894
): Promise<SentMessage<TRawMessage>>;
874895

875896
/**
@@ -1520,7 +1541,7 @@ export interface EmojiFormats {
15201541
* ```
15211542
*/
15221543
// biome-ignore lint/suspicious/noEmptyInterface: Required for TypeScript module augmentation
1523-
export interface CustomEmojiMap {}
1544+
export interface CustomEmojiMap { }
15241545

15251546
/**
15261547
* Full emoji type including well-known and custom emoji.

0 commit comments

Comments
 (0)