diff --git a/apps/docs/content/docs/guides/durable-chat-sessions-nextjs.mdx b/apps/docs/content/docs/guides/durable-chat-sessions-nextjs.mdx index 99aac829..564e93f4 100644 --- a/apps/docs/content/docs/guides/durable-chat-sessions-nextjs.mdx +++ b/apps/docs/content/docs/guides/durable-chat-sessions-nextjs.mdx @@ -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( @@ -114,6 +114,7 @@ async function postAssistantMessage( ) { "use step"; + const { bot } = await import("@/lib/bot"); await bot.initialize(); await thread.post(text); } @@ -121,6 +122,7 @@ async function postAssistantMessage( async function closeSession(thread: Thread) { "use step"; + const { bot } = await import("@/lib/bot"); await bot.initialize(); await thread.post("Session closed."); await thread.unsubscribe(); @@ -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; message: Message; }; @@ -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. + + 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. + + 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({ 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 @@ -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 diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index cf9c281f..35350a85 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -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, @@ -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 diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index a7e8bb59..34807719 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -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"; diff --git a/packages/chat/src/reviver.ts b/packages/chat/src/reviver.ts new file mode 100644 index 00000000..01e69837 --- /dev/null +++ b/packages/chat/src/reviver.ts @@ -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; +} diff --git a/packages/chat/src/serialization.test.ts b/packages/chat/src/serialization.test.ts index 81962cde..97a028e4 100644 --- a/packages/chat/src/serialization.test.ts +++ b/packages/chat/src/serialization.test.ts @@ -8,6 +8,7 @@ import { createMockState, createTestMessage, } from "./mock-adapter"; +import { reviver } from "./reviver"; import { type SerializedThread, ThreadImpl } from "./thread"; describe("Serialization", () => { @@ -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;