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
20 changes: 13 additions & 7 deletions apps/docs/content/docs/guides/durable-chat-sessions-nextjs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,14 @@ export type ChatTurnPayload = {

## Create the durable session workflow

The workflow receives the serialized thread and first message, restores them with `bot.reviver()`, and then keeps waiting for more turns through the hook.
The workflow receives the serialized thread and first message, restores them with `reviver`, and then keeps waiting for more turns through the hook.

The important detail is that the workflow only orchestrates. Chat SDK side effects such as `post()`, `unsubscribe()`, and `setState()` stay inside step helpers:

```typescript title="workflows/durable-chat-session.ts" lineNumbers
import { Message, type Thread } from "chat";
import { Message, reviver, type Thread } from "chat";
import { createHook, getWorkflowMetadata } from "workflow";
import { bot, type ThreadState } from "@/lib/bot";
import type { ThreadState } from "@/lib/bot";
import type { ChatTurnPayload } from "@/workflows/chat-turn-hook";

async function postAssistantMessage(
Expand All @@ -114,13 +114,15 @@ async function postAssistantMessage(
) {
"use step";

const { bot } = await import("@/lib/bot");
await bot.initialize();
await thread.post(text);
}

async function closeSession(thread: Thread<ThreadState>) {
"use step";

const { bot } = await import("@/lib/bot");
await bot.initialize();
await thread.post("Session closed.");
await thread.unsubscribe();
Expand Down Expand Up @@ -154,7 +156,7 @@ export async function durableChatSession(payload: string) {
"use workflow";

const { workflowRunId } = getWorkflowMetadata();
const { thread, message } = JSON.parse(payload, bot.reviver()) as {
const { thread, message } = JSON.parse(payload, reviver) as {
thread: Thread<ThreadState>;
message: Message;
};
Expand Down Expand Up @@ -186,11 +188,15 @@ export async function durableChatSession(payload: string) {
The `using` keyword requires TypeScript 5.2+ with `"lib": ["esnext.disposable"]` in your `tsconfig.json`. If you are on an older version, call `hook.dispose()` manually when the session ends.
</Callout>

<Callout type="warn">
Do not import `bot` at the top level of a workflow file. Adapter packages depend on Node.js modules that are not available in the workflow sandbox. Use the standalone `reviver` for deserialization and import `bot` dynamically inside `"use step"` functions where Node.js modules are available.
</Callout>

This is the core integration:

- `thread.toJSON()` and `message.toJSON()` cross the workflow boundary safely
- `bot.reviver()` restores real Chat SDK objects inside the workflow
- `bot.registerSingleton()` lets Workflow deserialize `Thread` objects again inside step functions
- `reviver` restores real Chat SDK objects inside the workflow without pulling in adapter dependencies
- `registerSingleton()` is called in `lib/bot.ts` and the singleton is available inside step functions when `bot` is dynamically imported
- `createHook<ChatTurnPayload>({ token: workflowRunId })` makes the workflow run itself the session identifier
- `runTurn()`, `postAssistantMessage()`, and `closeSession()` are steps, so adapter and state side effects stay outside the workflow sandbox

Expand Down Expand Up @@ -328,4 +334,4 @@ From here you can add:
- [Handling Events](/docs/handling-events) — Mentions, subscribed messages, and routing behavior
- [Streaming](/docs/streaming) — Stream AI SDK responses directly to chat platforms
- [Thread API](/docs/api/thread) — `thread.toJSON()`, `thread.setState()`, and other thread primitives
- [Chat API](/docs/api/chat) — `bot.reviver()`, initialization, and webhook access
- [Chat API](/docs/api/chat) — `reviver`, initialization, and webhook access
17 changes: 2 additions & 15 deletions packages/chat/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isJSX, toModalElement } from "./jsx-runtime";
import { Message, type SerializedMessage } from "./message";
import { MessageHistoryCache } from "./message-history";
import type { ModalElement } from "./modals";
import { reviver as standaloneReviver } from "./reviver";
import { type SerializedThread, ThreadImpl } from "./thread";
import type {
ActionEvent,
Expand Down Expand Up @@ -702,21 +703,7 @@ export class Chat<
reviver(): (key: string, value: unknown) => unknown {
// Ensure this chat instance is registered as singleton for thread deserialization
this.registerSingleton();
return function reviver(_key: string, value: unknown): unknown {
if (value && typeof value === "object" && "_type" in value) {
const typed = value as { _type: string };
if (typed._type === "chat:Thread") {
return ThreadImpl.fromJSON(value as SerializedThread);
}
if (typed._type === "chat:Channel") {
return ChannelImpl.fromJSON(value as SerializedChannel);
}
if (typed._type === "chat:Message") {
return Message.fromJSON(value as SerializedMessage);
}
}
return value;
};
return standaloneReviver;
}

// ChatInstance interface implementations
Expand Down
1 change: 1 addition & 0 deletions packages/chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
MessageHistoryCache,
type MessageHistoryConfig,
} from "./message-history";
export { reviver } from "./reviver";
export { StreamingMarkdownRenderer } from "./streaming-markdown";
export { type SerializedThread, ThreadImpl } from "./thread";

Expand Down
33 changes: 33 additions & 0 deletions packages/chat/src/reviver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Standalone JSON reviver for Chat SDK objects.
*
* Restores serialized Thread, Channel, and Message instances during
* JSON.parse() without requiring a Chat instance. This is useful in
* environments like Vercel Workflow functions where importing the full
* Chat instance (with its adapter dependencies) is not possible.
*
* Thread instances created this way use lazy adapter resolution —
* the adapter is looked up from the Chat singleton when first accessed,
* so `chat.registerSingleton()` must be called before using thread
* methods like `post()` (typically inside a "use step" function).
*/

import { ChannelImpl, type SerializedChannel } from "./channel";
import { Message, type SerializedMessage } from "./message";
import { type SerializedThread, ThreadImpl } from "./thread";

export function reviver(_key: string, value: unknown): unknown {
if (value && typeof value === "object" && "_type" in value) {
const typed = value as { _type: string };
if (typed._type === "chat:Thread") {
return ThreadImpl.fromJSON(value as SerializedThread);
}
if (typed._type === "chat:Channel") {
return ChannelImpl.fromJSON(value as SerializedChannel);
}
if (typed._type === "chat:Message") {
return Message.fromJSON(value as SerializedMessage);
}
}
return value;
}
150 changes: 150 additions & 0 deletions packages/chat/src/serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createMockState,
createTestMessage,
} from "./mock-adapter";
import { reviver } from "./reviver";
import { type SerializedThread, ThreadImpl } from "./thread";

describe("Serialization", () => {
Expand Down Expand Up @@ -679,6 +680,155 @@ describe("Serialization", () => {
});
});

describe("standalone reviver()", () => {
beforeEach(() => {
const mockState = createMockState();
const chat = new Chat({
userName: "test-bot",
adapters: {
slack: createMockAdapter("slack"),
teams: createMockAdapter("teams"),
},
state: mockState,
logger: "silent",
});
chat.registerSingleton();
});

afterEach(() => {
clearChatSingleton();
});

it("should revive chat:Thread objects", () => {
const json: SerializedThread = {
_type: "chat:Thread",
id: "slack:C123:1234.5678",
channelId: "C123",
isDM: false,
adapterName: "slack",
};

const payload = JSON.stringify({ thread: json });
const parsed = JSON.parse(payload, reviver);

expect(parsed.thread).toBeInstanceOf(ThreadImpl);
expect(parsed.thread.id).toBe("slack:C123:1234.5678");
});

it("should revive chat:Message objects", () => {
const json: SerializedMessage = {
_type: "chat:Message",
id: "msg-1",
threadId: "slack:C123:1234.5678",
text: "Hello",
formatted: { type: "root", children: [] },
raw: {},
author: {
userId: "U123",
userName: "testuser",
fullName: "Test User",
isBot: false,
isMe: false,
},
metadata: {
dateSent: "2024-01-15T10:30:00.000Z",
edited: false,
},
attachments: [],
};

const payload = JSON.stringify({ message: json });
const parsed = JSON.parse(payload, reviver);

expect(parsed.message.id).toBe("msg-1");
expect(parsed.message.metadata.dateSent).toBeInstanceOf(Date);
});

it("should revive both Thread and Message in same payload", () => {
const threadJson: SerializedThread = {
_type: "chat:Thread",
id: "slack:C123:1234.5678",
channelId: "C123",
isDM: false,
adapterName: "slack",
};

const messageJson: SerializedMessage = {
_type: "chat:Message",
id: "msg-1",
threadId: "slack:C123:1234.5678",
text: "Hello",
formatted: { type: "root", children: [] },
raw: {},
author: {
userId: "U123",
userName: "testuser",
fullName: "Test User",
isBot: false,
isMe: false,
},
metadata: {
dateSent: "2024-01-15T10:30:00.000Z",
edited: false,
},
attachments: [],
};

const payload = JSON.stringify({
thread: threadJson,
message: messageJson,
});
const parsed = JSON.parse(payload, reviver);

expect(parsed.thread).toBeInstanceOf(ThreadImpl);
expect(parsed.message.metadata.dateSent).toBeInstanceOf(Date);
});

it("should leave non-chat objects unchanged", () => {
const payload = JSON.stringify({
name: "test",
count: 42,
nested: { _type: "other:Type", value: "unchanged" },
});

const parsed = JSON.parse(payload, reviver);

expect(parsed.name).toBe("test");
expect(parsed.count).toBe(42);
expect(parsed.nested._type).toBe("other:Type");
});

it("should be usable directly as JSON.parse second argument", () => {
const json: SerializedMessage = {
_type: "chat:Message",
id: "msg-direct",
threadId: "slack:C123:1234.5678",
text: "Direct usage",
formatted: { type: "root", children: [] },
raw: {},
author: {
userId: "U123",
userName: "testuser",
fullName: "Test User",
isBot: false,
isMe: false,
},
metadata: {
dateSent: "2024-01-15T10:30:00.000Z",
edited: false,
},
attachments: [],
};

// This is the key use case: passing reviver directly without wrapping
const parsed = JSON.parse(JSON.stringify(json), reviver);

expect(parsed.id).toBe("msg-direct");
expect(parsed.text).toBe("Direct usage");
expect(parsed.metadata.dateSent).toBeInstanceOf(Date);
});
});

describe("@workflow/serde integration", () => {
let chat: Chat;
let mockState: ReturnType<typeof createMockState>;
Expand Down
Loading