Skip to content
Open
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
7 changes: 6 additions & 1 deletion apps/docs/content/docs/api/thread.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SentMessage>` — the sent message with `edit()`, `delete()`, `addReaction()`, and `removeReaction()` methods.

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/posting-messages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>` 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<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" } })`.

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

Expand Down
30 changes: 17 additions & 13 deletions apps/docs/content/docs/streaming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
});
```

Expand All @@ -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",
}],
},
],
},
});
```

Expand Down
2 changes: 2 additions & 0 deletions packages/chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,15 @@ export type {
ModalSubmitHandler,
ModalUpdateResponse,
PlanUpdateChunk,
PostOptions,
Postable,
PostableAst,
PostableCard,
PostableMarkdown,
PostableMessage,
PostableRaw,
PostEphemeralOptions,
PostStreamOptions,
RawMessage,
ReactionEvent,
ReactionHandler,
Expand Down
63 changes: 63 additions & 0 deletions packages/chat/src/thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
15 changes: 9 additions & 6 deletions packages/chat/src/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import type {
Author,
Channel,
EphemeralMessage,
PostOptions,
PostableMessage,
PostEphemeralOptions,
PostStreamOptions,
ScheduledMessage,
SentMessage,
StateAdapter,
Expand Down Expand Up @@ -105,8 +107,7 @@ function isAsyncIterable(
}

export class ThreadImpl<TState = Record<string, unknown>>
implements Thread<TState>
{
implements Thread<TState> {
readonly id: string;
readonly channelId: string;
readonly isDM: boolean;
Expand Down Expand Up @@ -368,11 +369,12 @@ export class ThreadImpl<TState = Record<string, unknown>>
}

async post(
message: string | PostableMessage | ChatElement
message: string | PostableMessage | ChatElement,
options?: PostOptions
): Promise<SentMessage> {
// Handle AsyncIterable (streaming)
if (isAsyncIterable(message)) {
return this.handleStream(message);
return this.handleStream(message, options?.stream);
}

// After filtering out streams, we have an AdapterPostableMessage
Expand Down Expand Up @@ -484,12 +486,13 @@ export class ThreadImpl<TState = Record<string, unknown>>
* then uses adapter's native streaming if available, otherwise falls back to post+edit.
*/
private async handleStream(
rawStream: AsyncIterable<string | StreamChunk | StreamEvent>
rawStream: AsyncIterable<string | StreamChunk | StreamEvent>,
streamOptions?: PostStreamOptions
): Promise<SentMessage> {
// 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
Expand Down
27 changes: 24 additions & 3 deletions packages/chat/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
} from "./errors";
export type { Logger, LogLevel } from "./logger";
export { ConsoleLogger } from "./logger";
export { Message } from "./message";

// =============================================================================
// Configuration
Expand Down Expand Up @@ -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<StreamOptions, "recipientUserId" | "recipientTeamId">;

/**
* 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 */
Expand Down Expand Up @@ -654,7 +671,8 @@ export interface Postable<
* Post a message.
*/
post(
message: string | PostableMessage | ChatElement
message: string | PostableMessage | ChatElement,
options?: PostOptions
): Promise<SentMessage<TRawMessage>>;

/**
Expand Down Expand Up @@ -865,11 +883,14 @@ export interface Thread<TState = Record<string, unknown>, 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<SentMessage<TRawMessage>>;

/**
Expand Down
Loading