From 3deb644137e205848a6c4c42e3a549564e42dc20 Mon Sep 17 00:00:00 2001 From: Kayden Humphries Date: Mon, 9 Mar 2026 00:31:22 -0600 Subject: [PATCH 1/2] Expose stream options on thread.post --- apps/docs/content/docs/api/thread.mdx | 7 ++- apps/docs/content/docs/posting-messages.mdx | 2 +- apps/docs/content/docs/streaming.mdx | 30 +++++----- packages/chat/src/index.ts | 2 + packages/chat/src/thread.test.ts | 63 +++++++++++++++++++++ packages/chat/src/thread.ts | 15 +++-- packages/chat/src/types.ts | 41 ++++++++++---- 7 files changed, 129 insertions(+), 31 deletions(-) diff --git a/apps/docs/content/docs/api/thread.mdx b/apps/docs/content/docs/api/thread.mdx index 2e9de901..af4efaa6 100644 --- a/apps/docs/content/docs/api/thread.mdx +++ b/apps/docs/content/docs/api/thread.mdx @@ -56,9 +56,14 @@ await thread.post(Card({ title: "Hi", children: [Text("Hello")] })); // Stream (fullStream recommended for multi-step agents) await thread.post(result.fullStream); + +// Stream with Slack-specific display options +await thread.post(result.fullStream, { + stream: { taskDisplayMode: "plan" }, +}); ``` -**Parameters:** `message: string | PostableMessage | CardJSXElement` +**Parameters:** `message: string | PostableMessage | CardJSXElement`, `options?: PostOptions` **Returns:** `Promise` — the sent message with `edit()`, `delete()`, `addReaction()`, and `removeReaction()` methods. diff --git a/apps/docs/content/docs/posting-messages.mdx b/apps/docs/content/docs/posting-messages.mdx index 507dad71..f950cac8 100644 --- a/apps/docs/content/docs/posting-messages.mdx +++ b/apps/docs/content/docs/posting-messages.mdx @@ -149,7 +149,7 @@ const result = await agent.stream({ prompt: message.text }); await thread.post(result.fullStream); ``` -Both `fullStream` and `textStream` are supported. Use `fullStream` with multi-step agents — it preserves paragraph breaks between steps. Any `AsyncIterable` also works for custom streams. +Both `fullStream` and `textStream` are supported. Use `fullStream` with multi-step agents — it preserves paragraph breaks between steps. Any `AsyncIterable` 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" } })`. See the [Streaming](/docs/streaming) page for details on platform behavior and configuration. diff --git a/apps/docs/content/docs/streaming.mdx b/apps/docs/content/docs/streaming.mdx index 877dae3f..f861fb6d 100644 --- a/apps/docs/content/docs/streaming.mdx +++ b/apps/docs/content/docs/streaming.mdx @@ -150,8 +150,10 @@ await thread.post(stream); Control how `task_update` chunks render in Slack by passing `taskDisplayMode` in stream options: ```typescript -await thread.stream(stream, { - taskDisplayMode: "plan", // Group all tasks into a single plan block +await thread.post(stream, { + stream: { + taskDisplayMode: "plan", // Group all tasks into a single plan block + }, }); ``` @@ -167,17 +169,19 @@ Adapters without structured chunk support extract text from `markdown_text` chun 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: ```typescript title="lib/bot.ts" lineNumbers -await thread.stream(textStream, { - stopBlocks: [ - { - type: "actions", - elements: [{ - type: "button", - text: { type: "plain_text", text: "Retry" }, - action_id: "retry", - }], - }, - ], +await thread.post(textStream, { + stream: { + stopBlocks: [ + { + type: "actions", + elements: [{ + type: "button", + text: { type: "plain_text", text: "Retry" }, + action_id: "retry", + }], + }, + ], + }, }); ``` diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index a7e8bb59..23540d8a 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -306,6 +306,7 @@ export type { ModalSubmitHandler, ModalUpdateResponse, PlanUpdateChunk, + PostOptions, Postable, PostableAst, PostableCard, @@ -313,6 +314,7 @@ export type { PostableMessage, PostableRaw, PostEphemeralOptions, + PostStreamOptions, RawMessage, ReactionEvent, ReactionHandler, diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index 742c7bb9..a68f03bc 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -575,6 +575,69 @@ describe("ThreadImpl", () => { }) ); }); + + it("should pass public stream options through thread.post", async () => { + const mockStream = vi.fn().mockResolvedValue({ + id: "msg-stream", + threadId: "t1", + raw: "Hello", + }); + mockAdapter.stream = mockStream; + + const textStream = createTextStream(["Hello"]); + await thread.post(textStream, { + stream: { + stopBlocks: [{ type: "actions" }], + taskDisplayMode: "plan", + }, + }); + + expect(mockStream).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.any(Object), + expect.objectContaining({ + stopBlocks: [{ type: "actions" }], + taskDisplayMode: "plan", + }) + ); + }); + + it("should ignore stream options for non-stream posts", async () => { + await thread.post("Hello world", { + stream: { + stopBlocks: [{ type: "actions" }], + taskDisplayMode: "plan", + }, + }); + + expect(mockAdapter.postMessage).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "Hello world" + ); + expect(mockAdapter.stream).toBeUndefined(); + }); + + it("should preserve fallback streaming behavior when public stream options are provided", async () => { + mockAdapter.stream = undefined; + + const textStream = createTextStream(["Hello", " ", "World"]); + await thread.post(textStream, { + stream: { + stopBlocks: [{ type: "actions" }], + taskDisplayMode: "plan", + }, + }); + + expect(mockAdapter.postMessage).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "..." + ); + expect(mockAdapter.editMessage).toHaveBeenLastCalledWith( + "slack:C123:1234.5678", + "msg-1", + { markdown: "Hello World" } + ); + }); }); describe("fallback streaming error logging", () => { diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index b58e359a..3d9267d9 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -23,8 +23,10 @@ import type { Author, Channel, EphemeralMessage, + PostOptions, PostableMessage, PostEphemeralOptions, + PostStreamOptions, ScheduledMessage, SentMessage, StateAdapter, @@ -105,8 +107,7 @@ function isAsyncIterable( } export class ThreadImpl> - implements Thread -{ + implements Thread { readonly id: string; readonly channelId: string; readonly isDM: boolean; @@ -368,11 +369,12 @@ export class ThreadImpl> } async post( - message: string | PostableMessage | ChatElement + message: string | PostableMessage | ChatElement, + options?: PostOptions ): Promise { // Handle AsyncIterable (streaming) if (isAsyncIterable(message)) { - return this.handleStream(message); + return this.handleStream(message, options?.stream); } // After filtering out streams, we have an AdapterPostableMessage @@ -484,12 +486,13 @@ export class ThreadImpl> * then uses adapter's native streaming if available, otherwise falls back to post+edit. */ private async handleStream( - rawStream: AsyncIterable + rawStream: AsyncIterable, + streamOptions?: PostStreamOptions ): Promise { // Normalize: handles plain strings, AI SDK fullStream events, and StreamChunk objects const textStream = fromFullStream(rawStream); // Build streaming options from current message context - const options: StreamOptions = {}; + const options: StreamOptions = { ...streamOptions }; if (this._currentMessage) { options.recipientUserId = this._currentMessage.author.userId; // Extract teamId from raw Slack payload diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 3f95dd40..673a2131 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -21,6 +21,7 @@ export { } from "./errors"; export type { Logger, LogLevel } from "./logger"; export { ConsoleLogger } from "./logger"; +export { Message } from "./message"; // ============================================================================= // Configuration @@ -76,12 +77,12 @@ export interface ChatConfig< * the token no longer matches. */ onLockConflict?: - | "force" - | "drop" - | (( - threadId: string, - message: Message - ) => "force" | "drop" | Promise<"force" | "drop">); + | "force" + | "drop" + | (( + threadId: string, + message: Message + ) => "force" | "drop" | Promise<"force" | "drop">); /** State adapter for subscriptions and locking */ state: StateAdapter; /** @@ -428,6 +429,22 @@ export interface StreamOptions { updateIntervalMs?: number; } +/** + * Public streaming options supported via `thread.post(stream, { stream: ... })`. + * Internal routing fields like recipient context remain adapter-managed. + */ +export type PostStreamOptions = Omit; + +/** + * Optional settings for `post()`. + * + * Streaming options are only applied when `message` is an async iterable. + */ +export interface PostOptions { + /** Options forwarded to native streaming adapters for async iterable messages. */ + stream?: PostStreamOptions; +} + /** Internal interface for Chat instance passed to adapters */ export interface ChatInstance { /** Get the configured logger, optionally with a child prefix */ @@ -654,7 +671,8 @@ export interface Postable< * Post a message. */ post( - message: string | PostableMessage | ChatElement + message: string | PostableMessage | ChatElement, + options?: PostOptions ): Promise>; /** @@ -865,11 +883,14 @@ export interface Thread, TRawMessage = unknown> * * // Stream from AI SDK * const result = await agent.stream({ prompt: message.text }); - * await thread.post(result.textStream); + * await thread.post(result.textStream, { + * stream: { taskDisplayMode: "plan" }, + * }); * ``` */ post( - message: string | PostableMessage | ChatElement + message: string | PostableMessage | ChatElement, + options?: PostOptions ): Promise>; /** @@ -1520,7 +1541,7 @@ export interface EmojiFormats { * ``` */ // biome-ignore lint/suspicious/noEmptyInterface: Required for TypeScript module augmentation -export interface CustomEmojiMap {} +export interface CustomEmojiMap { } /** * Full emoji type including well-known and custom emoji. From 9eb839d955f24de0232ea7505da9ac68c8f68a28 Mon Sep 17 00:00:00 2001 From: Kayden Humphries Date: Thu, 12 Mar 2026 16:17:27 -0600 Subject: [PATCH 2/2] chore: removed unrelated formatting change --- packages/chat/src/types.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 673a2131..9ba7b532 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -77,12 +77,12 @@ export interface ChatConfig< * the token no longer matches. */ onLockConflict?: - | "force" - | "drop" - | (( - threadId: string, - message: Message - ) => "force" | "drop" | Promise<"force" | "drop">); + | "force" + | "drop" + | (( + threadId: string, + message: Message + ) => "force" | "drop" | Promise<"force" | "drop">); /** State adapter for subscriptions and locking */ state: StateAdapter; /** @@ -1541,7 +1541,7 @@ export interface EmojiFormats { * ``` */ // biome-ignore lint/suspicious/noEmptyInterface: Required for TypeScript module augmentation -export interface CustomEmojiMap { } +export interface CustomEmojiMap {} /** * Full emoji type including well-known and custom emoji.