From 23b552ebbeee25c948b29c40559a3fb5b3041b9b Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Wed, 25 Feb 2026 15:28:02 -0800 Subject: [PATCH 1/9] [chat] adding plan/update plan blocks for threads --- packages/adapter-slack/src/index.ts | 171 +++++++++++++ packages/chat/src/index.ts | 10 + packages/chat/src/thread.test.ts | 232 ++++++++++++++++++ packages/chat/src/thread.ts | 239 +++++++++++++++++++ packages/chat/src/types.ts | 102 ++++++++ packages/integration-tests/src/slack.test.ts | 47 ++++ 6 files changed, 801 insertions(+) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 2a1ce9fb..5a976717 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -27,6 +27,7 @@ import type { Logger, ModalElement, ModalResponse, + PlanModel, RawMessage, ReactionEvent, StreamOptions, @@ -42,7 +43,9 @@ import { defaultEmojiResolver, isJSX, Message, + parseMarkdown, toModalElement, + toPlainText, } from "chat"; import { cardToBlockKit, cardToFallbackText } from "./cards"; import type { EncryptedTokenData } from "./crypto"; @@ -1987,6 +1990,174 @@ export class SlackAdapter implements Adapter { } } + // =========================================================================== + // Plan/Task blocks + // =========================================================================== + + async postPlan( + threadId: string, + plan: PlanModel + ): Promise> { + const { channel, threadTs } = this.decodeThreadId(threadId); + const text = this.renderPlanFallbackText(plan); + const blocks = this.planToBlockKit(plan); + + try { + this.logger.debug("Slack API: chat.postMessage (plan)", { + channel, + threadTs, + blockCount: blocks.length, + }); + const result = await this.client.chat.postMessage( + this.withToken({ + channel, + thread_ts: threadTs, + text, + // biome-ignore lint/suspicious/noExplicitAny: Block Kit blocks are platform-specific + blocks: blocks as any[], + unfurl_links: false, + unfurl_media: false, + }) + ); + return { id: result.ts as string, threadId, raw: result }; + } catch (error) { + this.handleSlackError(error); + } + } + + async editPlan( + threadId: string, + messageId: string, + plan: PlanModel + ): Promise> { + const { channel } = this.decodeThreadId(threadId); + const text = this.renderPlanFallbackText(plan); + const blocks = this.planToBlockKit(plan); + + try { + this.logger.debug("Slack API: chat.update (plan)", { + channel, + messageId, + blockCount: blocks.length, + }); + const result = await this.client.chat.update( + this.withToken({ + channel, + ts: messageId, + text, + // biome-ignore lint/suspicious/noExplicitAny: Block Kit blocks are platform-specific + blocks: blocks as any[], + }) + ); + + return { id: result.ts as string, threadId, raw: result }; + } catch (error) { + this.handleSlackError(error); + } + } + + private renderPlanFallbackText(plan: PlanModel): string { + const lines: string[] = []; + lines.push(plan.title || "Plan"); + for (const task of plan.tasks) { + lines.push(`- (${task.status}) ${task.title}`); + } + return lines.join("\n"); + } + + private planToBlockKit(plan: PlanModel): unknown[] { + const tasks = plan.tasks.map((task: PlanModel["tasks"][number]) => { + const details = this.planContentToRichText(task.details); + const output = this.planContentToRichText(task.output); + return { + type: "task_card", + task_id: task.id, + title: task.title, + status: task.status, + ...(details ? { details } : null), + ...(output ? { output } : null), + }; + }); + return [ + { + type: "plan", + title: plan.title || "Plan", + tasks, + }, + ]; + } + + private planContentToPlainText(content: unknown): string { + if (!content) { + return ""; + } + if (Array.isArray(content)) { + return content.join("\n"); + } + if (typeof content === "string") { + return content; + } + if ( + typeof content === "object" && + content !== null && + "markdown" in content + ) { + const markdown = (content as { markdown?: string }).markdown; + if (markdown) { + return toPlainText(parseMarkdown(markdown)); + } + return ""; + } + if (typeof content === "object" && content !== null && "ast" in content) { + const ast = (content as { ast?: unknown }).ast; + if (ast) { + return toPlainText(ast as Parameters[0]); + } + return ""; + } + return ""; + } + + private planContentToRichText( + content: unknown + ): { type: "rich_text"; elements: unknown[] } | undefined { + if (!content) { + return undefined; + } + if (Array.isArray(content)) { + return { + type: "rich_text", + elements: [ + { + type: "rich_text_list", + style: "bullet", + elements: content.map((item) => ({ + type: "rich_text_section", + elements: [{ type: "text", text: String(item) }], + })), + }, + ], + }; + } + const text = this.planContentToPlainText(content); + if (!text) { + return undefined; + } + return { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [{ type: "text", text }], + }, + ], + }; + } + + // =========================================================================== + // Message deletion and reactions + // =========================================================================== + async deleteMessage(threadId: string, messageId: string): Promise { const ephemeral = this.decodeEphemeralMessageId(messageId); if (ephemeral) { diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index cde5a7c3..139e9296 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -188,6 +188,7 @@ export type { ActionHandler, Adapter, AdapterPostableMessage, + AddTaskOptions, AppHomeOpenedEvent, AppHomeOpenedHandler, AssistantContextChangedEvent, @@ -200,6 +201,7 @@ export type { ChannelInfo, ChatConfig, ChatInstance, + CompletePlanOptions, CustomEmojiMap, Emoji, EmojiFormats, @@ -227,6 +229,12 @@ export type { ModalSubmitEvent, ModalSubmitHandler, ModalUpdateResponse, + PlanContent, + PlanMessage, + PlanModel, + PlanModelTask, + PlanTask, + PlanTaskStatus, Postable, PostableAst, PostableCard, @@ -240,12 +248,14 @@ export type { SentMessage, SlashCommandEvent, SlashCommandHandler, + StartPlanOptions, StateAdapter, StreamOptions, SubscribedMessageHandler, Thread, ThreadInfo, ThreadSummary, + UpdateTaskInput, WebhookOptions, WellKnownEmoji, } from "./types"; diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index f670559e..f59962e6 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -842,4 +842,236 @@ describe("ThreadImpl", () => { // Note: Streaming is prevented at the type level - postEphemeral accepts // AdapterPostableMessage | CardJSXElement which excludes AsyncIterable }); + + describe("postPlan", () => { + let thread: ThreadImpl; + let mockAdapter: Adapter; + let mockState: ReturnType; + + beforeEach(() => { + mockAdapter = createMockAdapter(); + mockState = createMockState(); + + thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + }); + }); + + it("should return no-op PlanMessage when adapter does not support plans", async () => { + // Adapter has no postPlan/editPlan methods by default + const plan = await thread.postPlan({ initialMessage: "Starting..." }); + + // Should still return a PlanMessage with inspectors working + expect(plan.title()).toBe("Starting..."); + expect(plan.tasks()).toHaveLength(1); + expect(plan.tasks()[0].status).toBe("in_progress"); + + // Methods should return null (no-op) + const task = await plan.addTask({ title: "Task 1" }); + expect(task).toBeNull(); + + const updated = await plan.updateTask("progress"); + expect(updated).toBeNull(); + + const reset = await plan.reset({ initialMessage: "Reset" }); + expect(reset).toBeNull(); + + // complete should not throw + await plan.complete({ completeMessage: "Done" }); + }); + + it("should call adapter postPlan when supported", async () => { + const mockPostPlan = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditPlan = vi.fn().mockResolvedValue(undefined); + mockAdapter.postPlan = mockPostPlan; + mockAdapter.editPlan = mockEditPlan; + + const plan = await thread.postPlan({ initialMessage: "Working..." }); + + expect(mockPostPlan).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.objectContaining({ + title: "Working...", + tasks: expect.arrayContaining([ + expect.objectContaining({ + title: "Working...", + status: "in_progress", + }), + ]), + }) + ); + expect(plan.id).toBe("plan-msg-1"); + }); + + it("should add tasks and call editPlan", async () => { + const mockPostPlan = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditPlan = vi.fn().mockResolvedValue(undefined); + mockAdapter.postPlan = mockPostPlan; + mockAdapter.editPlan = mockEditPlan; + + const plan = await thread.postPlan({ initialMessage: "Starting" }); + const task = await plan.addTask({ + title: "Fetch data", + children: ["Call API", "Parse response"], + }); + + expect(task).not.toBeNull(); + expect(task?.title).toBe("Fetch data"); + expect(task?.status).toBe("in_progress"); + expect(mockEditPlan).toHaveBeenCalled(); + + // Plan title should be updated to current task + expect(plan.title()).toBe("Fetch data"); + expect(plan.tasks()).toHaveLength(2); + }); + + it("should update current task with output", async () => { + const mockPostPlan = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditPlan = vi.fn().mockResolvedValue(undefined); + mockAdapter.postPlan = mockPostPlan; + mockAdapter.editPlan = mockEditPlan; + + const plan = await thread.postPlan({ initialMessage: "Working" }); + await plan.addTask({ title: "Step 1" }); + const updated = await plan.updateTask("Got result: 42"); + + expect(updated).not.toBeNull(); + expect(mockEditPlan).toHaveBeenCalled(); + }); + + it("should complete plan and mark tasks done", async () => { + const mockPostPlan = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditPlan = vi.fn().mockResolvedValue(undefined); + mockAdapter.postPlan = mockPostPlan; + mockAdapter.editPlan = mockEditPlan; + + const plan = await thread.postPlan({ initialMessage: "Starting" }); + await plan.addTask({ title: "Task 1" }); + await plan.complete({ completeMessage: "All done!" }); + + expect(plan.title()).toBe("All done!"); + // All tasks should be completed + for (const task of plan.tasks()) { + expect(task.status).toBe("complete"); + } + }); + + it("should reset plan and start fresh", async () => { + const mockPostPlan = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditPlan = vi.fn().mockResolvedValue(undefined); + mockAdapter.postPlan = mockPostPlan; + mockAdapter.editPlan = mockEditPlan; + + const plan = await thread.postPlan({ initialMessage: "First run" }); + await plan.addTask({ title: "Task A" }); + await plan.addTask({ title: "Task B" }); + + expect(plan.tasks()).toHaveLength(3); + + const newTask = await plan.reset({ initialMessage: "Second run" }); + expect(newTask).not.toBeNull(); + expect(plan.title()).toBe("Second run"); + expect(plan.tasks()).toHaveLength(1); + expect(plan.tasks()[0].status).toBe("in_progress"); + }); + + it("should return currentTask correctly", async () => { + const mockPostPlan = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditPlan = vi.fn().mockResolvedValue(undefined); + mockAdapter.postPlan = mockPostPlan; + mockAdapter.editPlan = mockEditPlan; + + const plan = await thread.postPlan({ initialMessage: "Start" }); + + // Initially, current task is the first one + let current = plan.currentTask(); + expect(current?.title).toBe("Start"); + expect(current?.status).toBe("in_progress"); + + // After adding a new task, current should be the new one + await plan.addTask({ title: "Step 2" }); + current = plan.currentTask(); + expect(current?.title).toBe("Step 2"); + expect(current?.status).toBe("in_progress"); + + // After completion, currentTask returns the last task + await plan.complete({ completeMessage: "Done" }); + current = plan.currentTask(); + expect(current?.title).toBe("Step 2"); + expect(current?.status).toBe("complete"); + }); + + it("should handle various PlanContent formats in initialMessage", async () => { + const mockPostPlan = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditPlan = vi.fn().mockResolvedValue(undefined); + mockAdapter.postPlan = mockPostPlan; + mockAdapter.editPlan = mockEditPlan; + + // String + let plan = await thread.postPlan({ initialMessage: "Simple string" }); + expect(plan.title()).toBe("Simple string"); + + // Array of strings + plan = await thread.postPlan({ initialMessage: ["Line 1", "Line 2"] }); + expect(plan.title()).toBe("Line 1 Line 2"); + + // Empty string defaults to "Plan" + plan = await thread.postPlan({ initialMessage: "" }); + expect(plan.title()).toBe("Plan"); + }); + + it("should ensure sequential edits via queue", async () => { + const editOrder: number[] = []; + let editCount = 0; + + const mockPostPlan = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditPlan = vi.fn().mockImplementation(async () => { + const myOrder = ++editCount; + // Simulate varying async delays + await new Promise((r) => setTimeout(r, Math.random() * 10)); + editOrder.push(myOrder); + }); + mockAdapter.postPlan = mockPostPlan; + mockAdapter.editPlan = mockEditPlan; + + const plan = await thread.postPlan({ initialMessage: "Start" }); + + // Fire off multiple updates concurrently + await Promise.all([ + plan.addTask({ title: "Task 1" }), + plan.updateTask("Output 1"), + plan.addTask({ title: "Task 2" }), + ]); + + // Despite random delays, edits should complete in order + expect(editOrder).toEqual([1, 2, 3]); + }); + }); }); diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index c490ed29..5dc3c352 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -15,16 +15,25 @@ import { Message, type SerializedMessage } from "./message"; import type { Adapter, AdapterPostableMessage, + AddTaskOptions, Attachment, Author, Channel, + CompletePlanOptions, EphemeralMessage, + PlanContent, + PlanMessage, + PlanModel, + PlanModelTask, + PlanTask, PostableMessage, PostEphemeralOptions, SentMessage, + StartPlanOptions, StateAdapter, StreamOptions, Thread, + UpdateTaskInput, } from "./types"; import { THREAD_STATE_TTL_MS } from "./types"; @@ -90,6 +99,201 @@ function isAsyncIterable(value: unknown): value is AsyncIterable { ); } +/** + * Convert PlanContent to plain text for titles/labels. + */ +function contentToPlainText(content: PlanContent | undefined): string { + if (!content) { + return ""; + } + if (Array.isArray(content)) { + return content.join(" ").trim(); + } + if (typeof content === "string") { + return content; + } + if ("markdown" in content) { + return toPlainText(parseMarkdown(content.markdown)); + } + if ("ast" in content) { + return toPlainText(content.ast); + } + return ""; +} + +interface PlanSession { + messageId: string; + plan: PlanModel; + threadIdForEdits: string; + updateChain: Promise; +} + +class PlanMessageImpl implements PlanMessage { + readonly id: string; + readonly threadId: string; + + private readonly adapter: Adapter; + private readonly supported: boolean; + private readonly session: PlanSession; + + constructor(options: { + adapter: Adapter; + supported: boolean; + threadId: string; + messageId: string; + threadIdForEdits: string; + plan: PlanModel; + }) { + this.adapter = options.adapter; + this.supported = options.supported; + this.threadId = options.threadId; + this.id = options.messageId; + this.session = { + messageId: options.messageId, + threadIdForEdits: options.threadIdForEdits, + plan: options.plan, + updateChain: Promise.resolve(), + }; + } + + title(): string { + return this.session.plan.title; + } + + tasks(): PlanTask[] { + return this.session.plan.tasks.map((t) => ({ + id: t.id, + title: t.title, + status: t.status, + })); + } + + currentTask(): PlanTask | null { + const current = + [...this.session.plan.tasks] + .reverse() + .find((t) => t.status === "in_progress") ?? + this.session.plan.tasks.at(-1); + if (!current) { + return null; + } + return { id: current.id, title: current.title, status: current.status }; + } + + async reset(options: StartPlanOptions): Promise { + if (!this.supported) { + return null; + } + + const title = this.contentToText(options.initialMessage) || "Plan"; + const firstTask: PlanModelTask = { + id: crypto.randomUUID(), + title, + status: "in_progress", + }; + this.session.plan = { title, tasks: [firstTask] }; + await this.enqueueEdit(); + return { + id: firstTask.id, + title: firstTask.title, + status: firstTask.status, + }; + } + + async addTask(options: AddTaskOptions): Promise { + if (!this.supported) { + return null; + } + const title = this.contentToText(options.title) || "Task"; + for (const task of this.session.plan.tasks) { + if (task.status === "in_progress") { + task.status = "complete"; + } + } + const nextTask: PlanModelTask = { + id: crypto.randomUUID(), + title, + status: "in_progress", + details: options.children, + }; + this.session.plan.tasks.push(nextTask); + this.session.plan.title = title; + + await this.enqueueEdit(); + return { id: nextTask.id, title: nextTask.title, status: nextTask.status }; + } + + async updateTask(update?: UpdateTaskInput): Promise { + if (!this.supported) { + return null; + } + const current = + [...this.session.plan.tasks] + .reverse() + .find((t) => t.status === "in_progress") ?? + this.session.plan.tasks.at(-1); + + if (!current) { + return null; + } + if (update !== undefined) { + if (typeof update === "object" && update !== null && "output" in update) { + if (update.output !== undefined) { + current.output = update.output; + } + if (update.status) { + current.status = update.status; + } + } else { + current.output = update as PlanContent; + } + } + await this.enqueueEdit(); + return { id: current.id, title: current.title, status: current.status }; + } + + async complete(options: CompletePlanOptions): Promise { + if (!this.supported) { + return; + } + for (const task of this.session.plan.tasks) { + if (task.status === "in_progress") { + task.status = "complete"; + } + } + this.session.plan.title = + this.contentToText(options.completeMessage) || this.session.plan.title; + await this.enqueueEdit(); + } + + private contentToText(content: PlanContent | undefined): string { + return contentToPlainText(content); + } + + private enqueueEdit(): Promise { + const editPlan = this.adapter.editPlan; + if (!editPlan) { + return Promise.resolve(); + } + const doEdit = async (): Promise => { + await editPlan.call( + this.adapter, + this.session.threadIdForEdits, + this.session.messageId, + this.session.plan + ); + }; + const chained = this.session.updateChain.then(doEdit, doEdit); + this.session.updateChain = chained.then( + () => undefined, + (err) => { + console.warn("[PlanMessage] Failed to edit plan:", err); + } + ); + return chained; + } +} + export class ThreadImpl> implements Thread { @@ -528,6 +732,41 @@ export class ThreadImpl> this._recentMessages = result.messages; } + async postPlan(options: StartPlanOptions): Promise { + const adapter = this.adapter; + const postPlan = adapter.postPlan; + const editPlan = adapter.editPlan; + + const title = contentToPlainText(options.initialMessage) || "Plan"; + const firstTask: PlanModelTask = { + id: crypto.randomUUID(), + title, + status: "in_progress", + }; + const plan: PlanModel = { title, tasks: [firstTask] }; + + if (!(postPlan && editPlan)) { + return new PlanMessageImpl({ + adapter, + supported: false, + threadId: this.id, + messageId: `plan_${crypto.randomUUID()}`, + threadIdForEdits: this.id, + plan, + }); + } + const raw = await postPlan.call(adapter, this.id, plan); + const threadIdForEdits = raw.threadId ?? this.id; + return new PlanMessageImpl({ + adapter, + supported: true, + threadId: threadIdForEdits, + messageId: raw.id, + threadIdForEdits, + plan, + }); + } + mentionUser(userId: string): string { return `<@${userId}>`; } diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 9982e4aa..73835a67 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -117,6 +117,16 @@ export interface Adapter { message: AdapterPostableMessage ): Promise>; + /** + * Optional: edit a previously posted plan message. + * If not implemented, plan helpers will no-op. + */ + editPlan?( + threadId: string, + messageId: string, + plan: PlanModel + ): Promise>; + /** Encode platform-specific data into a thread ID string */ encodeThreadId(platformData: TThreadId): string; @@ -280,6 +290,15 @@ export interface Adapter { message: AdapterPostableMessage ): Promise>; + /** + * Optional: post a plan/tasks surface as a single message. + * If not implemented, plan helpers will no-op. + */ + postPlan?( + threadId: string, + plan: PlanModel + ): Promise>; + /** Remove a reaction from a message */ removeReaction( threadId: string, @@ -755,6 +774,14 @@ export interface Thread, TRawMessage = unknown> options: PostEphemeralOptions ): Promise; + /** + * Post a new plan message in this thread. + * + * Platforms that don't support native plan surfaces will return a PlanMessage + * that no-ops (v1 behavior). + */ + postPlan(options: StartPlanOptions): Promise; + /** Recently fetched messages (cached) */ recentMessages: Message[]; @@ -797,6 +824,81 @@ export interface Thread, TRawMessage = unknown> unsubscribe(): Promise; } +export type PlanTaskStatus = "pending" | "in_progress" | "complete" | "error"; + +export interface PlanTask { + id: string; + status: PlanTaskStatus; + title: string; +} + +export interface PlanMessage { + /** Add a task and set it in progress. */ + addTask(options: AddTaskOptions): Promise; + /** Complete the plan and mark the current task complete. */ + complete(options: CompletePlanOptions): Promise; + /** Returns the in-progress task, or the last task if none is in-progress. */ + currentTask(): PlanTask | null; + /** The underlying message ID on the platform (or a synthetic ID if unsupported). */ + id: string; + + /** Reset the plan contents and overwrite the same message. */ + reset(options: StartPlanOptions): Promise; + /** All tasks. */ + tasks(): PlanTask[]; + /** Thread ID where the plan was posted. */ + threadId: string; + + /** Current plan title. */ + title(): string; + /** Update the current task (typically output). */ + updateTask(update?: UpdateTaskInput): Promise; +} + +export interface PlanModel { + tasks: PlanModelTask[]; + title: string; +} + +export interface PlanModelTask { + details?: PlanContent; + id: string; + output?: PlanContent; + status: PlanTaskStatus; + title: string; +} + +export type PlanContent = + | string + | string[] + | { markdown: string } + | { ast: Root }; + +export interface StartPlanOptions { + /** Initial plan title and first task title */ + initialMessage: PlanContent; +} + +export interface AddTaskOptions { + /** Task details/substeps. */ + children?: PlanContent; + title: PlanContent; +} + +export type UpdateTaskInput = + | PlanContent + | { + /** Task output/results. */ + output?: PlanContent; + /** Optional status override. */ + status?: PlanTaskStatus; + }; + +export interface CompletePlanOptions { + /** Final plan title shown when completed */ + completeMessage: PlanContent; +} + export interface ThreadInfo { channelId: string; channelName?: string; diff --git a/packages/integration-tests/src/slack.test.ts b/packages/integration-tests/src/slack.test.ts index 75a9020e..fbe49666 100644 --- a/packages/integration-tests/src/slack.test.ts +++ b/packages/integration-tests/src/slack.test.ts @@ -453,6 +453,53 @@ describe("Slack Integration", () => { expect.objectContaining({ text: "Done typing!" }) ); }); + + it("should render plan/task blocks and update in-place", async () => { + chat.onNewMention(async (thread) => { + const plan = await thread.postPlan({ initialMessage: "Working..." }); + await plan.addTask({ + title: "Fetch data", + children: ["Call API"], + }); + await plan.updateTask("Received response"); + await plan.complete({ completeMessage: "Done" }); + }); + + const event = createSlackEvent({ + type: "app_mention", + text: `@${SLACK_BOT_USERNAME} plan test`, + userId: "U_USER_123", + messageTs: "1234567890.111111", + threadTs: TEST_THREAD_TS, + channel: TEST_CHANNEL, + }); + + await chat.webhooks.slack(createSlackWebhookRequest(event), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + + expect(mockClient.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: [ + expect.objectContaining({ + type: "plan", + title: "Working...", + }), + ], + }) + ); + expect(mockClient.chat.update).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: [ + expect.objectContaining({ + type: "plan", + title: "Done", + }), + ], + }) + ); + }); }); describe("multi-message conversation flow", () => { From 2bc7db5c6b72141e48ba17b665cc37a590a983df Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Mon, 2 Mar 2026 00:39:46 -0800 Subject: [PATCH 2/9] refactor plan impl into its own file --- packages/chat/src/plan.ts | 208 ++++++++++++++++++++++++++++++++++++ packages/chat/src/thread.ts | 201 +--------------------------------- 2 files changed, 209 insertions(+), 200 deletions(-) create mode 100644 packages/chat/src/plan.ts diff --git a/packages/chat/src/plan.ts b/packages/chat/src/plan.ts new file mode 100644 index 00000000..03a4d150 --- /dev/null +++ b/packages/chat/src/plan.ts @@ -0,0 +1,208 @@ +import { parseMarkdown, toPlainText } from "./markdown"; +import type { + Adapter, + AddTaskOptions, + CompletePlanOptions, + PlanContent, + PlanMessage, + PlanModel, + PlanModelTask, + PlanTask, + StartPlanOptions, + UpdateTaskInput, +} from "./types"; + +/** + * Convert PlanContent to plain text for titles/labels. + */ +export function contentToPlainText(content: PlanContent | undefined): string { + if (!content) { + return ""; + } + if (Array.isArray(content)) { + return content.join(" ").trim(); + } + if (typeof content === "string") { + return content; + } + if ("markdown" in content) { + return toPlainText(parseMarkdown(content.markdown)); + } + if ("ast" in content) { + return toPlainText(content.ast); + } + return ""; +} + +interface PlanSession { + messageId: string; + plan: PlanModel; + threadIdForEdits: string; + updateChain: Promise; +} + +export class PlanMessageImpl implements PlanMessage { + readonly id: string; + readonly threadId: string; + + private readonly adapter: Adapter; + private readonly supported: boolean; + private readonly session: PlanSession; + + constructor(options: { + adapter: Adapter; + supported: boolean; + threadId: string; + messageId: string; + threadIdForEdits: string; + plan: PlanModel; + }) { + this.adapter = options.adapter; + this.supported = options.supported; + this.threadId = options.threadId; + this.id = options.messageId; + this.session = { + messageId: options.messageId, + threadIdForEdits: options.threadIdForEdits, + plan: options.plan, + updateChain: Promise.resolve(), + }; + } + + title(): string { + return this.session.plan.title; + } + + tasks(): PlanTask[] { + return this.session.plan.tasks.map((t) => ({ + id: t.id, + title: t.title, + status: t.status, + })); + } + + currentTask(): PlanTask | null { + const current = + [...this.session.plan.tasks] + .reverse() + .find((t) => t.status === "in_progress") ?? + this.session.plan.tasks.at(-1); + if (!current) { + return null; + } + return { id: current.id, title: current.title, status: current.status }; + } + + async reset(options: StartPlanOptions): Promise { + if (!this.supported) { + return null; + } + + const title = this.contentToText(options.initialMessage) || "Plan"; + const firstTask: PlanModelTask = { + id: crypto.randomUUID(), + title, + status: "in_progress", + }; + this.session.plan = { title, tasks: [firstTask] }; + await this.enqueueEdit(); + return { + id: firstTask.id, + title: firstTask.title, + status: firstTask.status, + }; + } + + async addTask(options: AddTaskOptions): Promise { + if (!this.supported) { + return null; + } + const title = this.contentToText(options.title) || "Task"; + for (const task of this.session.plan.tasks) { + if (task.status === "in_progress") { + task.status = "complete"; + } + } + const nextTask: PlanModelTask = { + id: crypto.randomUUID(), + title, + status: "in_progress", + details: options.children, + }; + this.session.plan.tasks.push(nextTask); + this.session.plan.title = title; + + await this.enqueueEdit(); + return { id: nextTask.id, title: nextTask.title, status: nextTask.status }; + } + + async updateTask(update?: UpdateTaskInput): Promise { + if (!this.supported) { + return null; + } + const current = + [...this.session.plan.tasks] + .reverse() + .find((t) => t.status === "in_progress") ?? + this.session.plan.tasks.at(-1); + + if (!current) { + return null; + } + if (update !== undefined) { + if (typeof update === "object" && update !== null && "output" in update) { + if (update.output !== undefined) { + current.output = update.output; + } + if (update.status) { + current.status = update.status; + } + } else { + current.output = update as PlanContent; + } + } + await this.enqueueEdit(); + return { id: current.id, title: current.title, status: current.status }; + } + + async complete(options: CompletePlanOptions): Promise { + if (!this.supported) { + return; + } + for (const task of this.session.plan.tasks) { + if (task.status === "in_progress") { + task.status = "complete"; + } + } + this.session.plan.title = + this.contentToText(options.completeMessage) || this.session.plan.title; + await this.enqueueEdit(); + } + + private contentToText(content: PlanContent | undefined): string { + return contentToPlainText(content); + } + + private enqueueEdit(): Promise { + const editPlan = this.adapter.editPlan; + if (!editPlan) { + return Promise.resolve(); + } + const doEdit = async (): Promise => { + await editPlan.call( + this.adapter, + this.session.threadIdForEdits, + this.session.messageId, + this.session.plan + ); + }; + const chained = this.session.updateChain.then(doEdit, doEdit); + this.session.updateChain = chained.then( + () => undefined, + (err) => { + console.warn("[PlanMessage] Failed to edit plan:", err); + } + ); + return chained; + } +} diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index 95373ca8..9c0d83bc 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -12,20 +12,17 @@ import { toPlainText, } from "./markdown"; import { Message, type SerializedMessage } from "./message"; +import { contentToPlainText, PlanMessageImpl } from "./plan"; import type { Adapter, AdapterPostableMessage, - AddTaskOptions, Attachment, Author, Channel, - CompletePlanOptions, EphemeralMessage, - PlanContent, PlanMessage, PlanModel, PlanModelTask, - PlanTask, PostableMessage, PostEphemeralOptions, SentMessage, @@ -33,7 +30,6 @@ import type { StateAdapter, StreamOptions, Thread, - UpdateTaskInput, } from "./types"; import { THREAD_STATE_TTL_MS } from "./types"; @@ -101,201 +97,6 @@ function isAsyncIterable(value: unknown): value is AsyncIterable { ); } -/** - * Convert PlanContent to plain text for titles/labels. - */ -function contentToPlainText(content: PlanContent | undefined): string { - if (!content) { - return ""; - } - if (Array.isArray(content)) { - return content.join(" ").trim(); - } - if (typeof content === "string") { - return content; - } - if ("markdown" in content) { - return toPlainText(parseMarkdown(content.markdown)); - } - if ("ast" in content) { - return toPlainText(content.ast); - } - return ""; -} - -interface PlanSession { - messageId: string; - plan: PlanModel; - threadIdForEdits: string; - updateChain: Promise; -} - -class PlanMessageImpl implements PlanMessage { - readonly id: string; - readonly threadId: string; - - private readonly adapter: Adapter; - private readonly supported: boolean; - private readonly session: PlanSession; - - constructor(options: { - adapter: Adapter; - supported: boolean; - threadId: string; - messageId: string; - threadIdForEdits: string; - plan: PlanModel; - }) { - this.adapter = options.adapter; - this.supported = options.supported; - this.threadId = options.threadId; - this.id = options.messageId; - this.session = { - messageId: options.messageId, - threadIdForEdits: options.threadIdForEdits, - plan: options.plan, - updateChain: Promise.resolve(), - }; - } - - title(): string { - return this.session.plan.title; - } - - tasks(): PlanTask[] { - return this.session.plan.tasks.map((t) => ({ - id: t.id, - title: t.title, - status: t.status, - })); - } - - currentTask(): PlanTask | null { - const current = - [...this.session.plan.tasks] - .reverse() - .find((t) => t.status === "in_progress") ?? - this.session.plan.tasks.at(-1); - if (!current) { - return null; - } - return { id: current.id, title: current.title, status: current.status }; - } - - async reset(options: StartPlanOptions): Promise { - if (!this.supported) { - return null; - } - - const title = this.contentToText(options.initialMessage) || "Plan"; - const firstTask: PlanModelTask = { - id: crypto.randomUUID(), - title, - status: "in_progress", - }; - this.session.plan = { title, tasks: [firstTask] }; - await this.enqueueEdit(); - return { - id: firstTask.id, - title: firstTask.title, - status: firstTask.status, - }; - } - - async addTask(options: AddTaskOptions): Promise { - if (!this.supported) { - return null; - } - const title = this.contentToText(options.title) || "Task"; - for (const task of this.session.plan.tasks) { - if (task.status === "in_progress") { - task.status = "complete"; - } - } - const nextTask: PlanModelTask = { - id: crypto.randomUUID(), - title, - status: "in_progress", - details: options.children, - }; - this.session.plan.tasks.push(nextTask); - this.session.plan.title = title; - - await this.enqueueEdit(); - return { id: nextTask.id, title: nextTask.title, status: nextTask.status }; - } - - async updateTask(update?: UpdateTaskInput): Promise { - if (!this.supported) { - return null; - } - const current = - [...this.session.plan.tasks] - .reverse() - .find((t) => t.status === "in_progress") ?? - this.session.plan.tasks.at(-1); - - if (!current) { - return null; - } - if (update !== undefined) { - if (typeof update === "object" && update !== null && "output" in update) { - if (update.output !== undefined) { - current.output = update.output; - } - if (update.status) { - current.status = update.status; - } - } else { - current.output = update as PlanContent; - } - } - await this.enqueueEdit(); - return { id: current.id, title: current.title, status: current.status }; - } - - async complete(options: CompletePlanOptions): Promise { - if (!this.supported) { - return; - } - for (const task of this.session.plan.tasks) { - if (task.status === "in_progress") { - task.status = "complete"; - } - } - this.session.plan.title = - this.contentToText(options.completeMessage) || this.session.plan.title; - await this.enqueueEdit(); - } - - private contentToText(content: PlanContent | undefined): string { - return contentToPlainText(content); - } - - private enqueueEdit(): Promise { - const editPlan = this.adapter.editPlan; - if (!editPlan) { - return Promise.resolve(); - } - const doEdit = async (): Promise => { - await editPlan.call( - this.adapter, - this.session.threadIdForEdits, - this.session.messageId, - this.session.plan - ); - }; - const chained = this.session.updateChain.then(doEdit, doEdit); - this.session.updateChain = chained.then( - () => undefined, - (err) => { - console.warn("[PlanMessage] Failed to edit plan:", err); - } - ); - return chained; - } -} - export class ThreadImpl> implements Thread { From 46d021acba14bfd7ac46bb173794cb333b69488b Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Tue, 3 Mar 2026 15:35:15 -0800 Subject: [PATCH 3/9] thread.post() instead of thread.postPlan() --- packages/chat/src/channel.ts | 23 ++++- packages/chat/src/index.ts | 1 + packages/chat/src/plan.ts | 159 +++++++++++++++++++------------ packages/chat/src/thread.test.ts | 34 ++++--- packages/chat/src/thread.ts | 61 ++++-------- packages/chat/src/types.ts | 24 ++--- 6 files changed, 177 insertions(+), 125 deletions(-) diff --git a/packages/chat/src/channel.ts b/packages/chat/src/channel.ts index c5833346..a0db33b5 100644 --- a/packages/chat/src/channel.ts +++ b/packages/chat/src/channel.ts @@ -10,6 +10,7 @@ import { toPlainText, } from "./markdown"; import { Message } from "./message"; +import { isPlan, Plan } from "./plan"; import type { Adapter, AdapterPostableMessage, @@ -17,6 +18,7 @@ import type { Channel, ChannelInfo, EphemeralMessage, + PlanMessage, PostableMessage, PostEphemeralOptions, SentMessage, @@ -241,7 +243,12 @@ export class ChannelImpl> async post( message: string | PostableMessage | CardJSXElement - ): Promise { + ): Promise { + // Handle Plan objects + if (isPlan(message)) { + return this.handlePlanPost(message); + } + // Handle AsyncIterable (streaming) — not supported at channel level, // fall through to postMessage if (isAsyncIterable(message)) { @@ -268,6 +275,20 @@ export class ChannelImpl> return this.postSingleMessage(postable); } + private async handlePlanPost(plan: Plan): Promise { + const adapter = this.adapter; + + if (adapter.postPlan && adapter.editPlan) { + const raw = await adapter.postPlan(this.id, plan._toModel()); + const threadIdForEdits = raw.threadId ?? this.id; + plan._bind(adapter, this.id, raw.id, threadIdForEdits); + } else { + plan._bind(adapter, this.id, `plan_${crypto.randomUUID()}`, this.id); + } + + return plan; + } + private async postSingleMessage( postable: AdapterPostableMessage ): Promise { diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 9a8a07d5..75b6fc63 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -11,6 +11,7 @@ export { type MessageData, type SerializedMessage, } from "./message"; +export { isPlan, Plan } from "./plan"; export { StreamingMarkdownRenderer } from "./streaming-markdown"; export { type SerializedThread, ThreadImpl } from "./thread"; diff --git a/packages/chat/src/plan.ts b/packages/chat/src/plan.ts index 03a4d150..c5fe473a 100644 --- a/packages/chat/src/plan.ts +++ b/packages/chat/src/plan.ts @@ -34,47 +34,88 @@ export function contentToPlainText(content: PlanContent | undefined): string { return ""; } -interface PlanSession { +const PLAN_TYPE = Symbol.for("chat.plan"); +export function isPlan(value: unknown): value is Plan { + return ( + typeof value === "object" && + value !== null && + (value as Plan).$$typeof === PLAN_TYPE + ); +} + +interface BoundState { + adapter: Adapter; messageId: string; - plan: PlanModel; + threadId: string; threadIdForEdits: string; updateChain: Promise; } -export class PlanMessageImpl implements PlanMessage { - readonly id: string; - readonly threadId: string; - - private readonly adapter: Adapter; - private readonly supported: boolean; - private readonly session: PlanSession; - - constructor(options: { - adapter: Adapter; - supported: boolean; - threadId: string; - messageId: string; - threadIdForEdits: string; - plan: PlanModel; - }) { - this.adapter = options.adapter; - this.supported = options.supported; - this.threadId = options.threadId; - this.id = options.messageId; - this.session = { - messageId: options.messageId, - threadIdForEdits: options.threadIdForEdits, - plan: options.plan, +/** + * A Plan represents a task list that can be posted to a thread. + * + * Create a plan with `Plan({ initialMessage: "..." })` and post it with `thread.post(plan)`. + * After posting, use methods like `addTask()`, `updateTask()`, and `complete()` to update it. + * + * @example + * ```typescript + * const plan = Plan({ initialMessage: "Starting task..." }); + * await thread.post(plan); + * await plan.addTask({ title: "Fetch data" }); + * await plan.updateTask("Got 42 results"); + * await plan.complete({ completeMessage: "Done!" }); + * ``` + */ +export class Plan implements PlanMessage { + readonly $$typeof = PLAN_TYPE; + + private _plan: PlanModel; + private _bound: BoundState | null = null; + + constructor(options: StartPlanOptions) { + const title = contentToPlainText(options.initialMessage) || "Plan"; + const firstTask: PlanModelTask = { + id: crypto.randomUUID(), + title, + status: "in_progress", + }; + this._plan = { title, tasks: [firstTask] }; + } + + get id(): string { + return this._bound?.messageId ?? ""; + } + get threadId(): string { + return this._bound?.threadId ?? ""; + } + + _bind( + adapter: Adapter, + threadId: string, + messageId: string, + threadIdForEdits: string + ): void { + this._bound = { + adapter, + messageId, + threadId, + threadIdForEdits, updateChain: Promise.resolve(), }; } + _toModel(): PlanModel { + return this._plan; + } + _isSupported(): boolean { + return !!(this._bound?.adapter.postPlan && this._bound?.adapter.editPlan); + } title(): string { - return this.session.plan.title; + return this._plan.title; } tasks(): PlanTask[] { - return this.session.plan.tasks.map((t) => ({ + return this._plan.tasks.map((t) => ({ id: t.id, title: t.title, status: t.status, @@ -83,10 +124,8 @@ export class PlanMessageImpl implements PlanMessage { currentTask(): PlanTask | null { const current = - [...this.session.plan.tasks] - .reverse() - .find((t) => t.status === "in_progress") ?? - this.session.plan.tasks.at(-1); + [...this._plan.tasks].reverse().find((t) => t.status === "in_progress") ?? + this._plan.tasks.at(-1); if (!current) { return null; } @@ -94,17 +133,17 @@ export class PlanMessageImpl implements PlanMessage { } async reset(options: StartPlanOptions): Promise { - if (!this.supported) { + if (!this._bound || !this._isSupported()) { return null; } - const title = this.contentToText(options.initialMessage) || "Plan"; + const title = contentToPlainText(options.initialMessage) || "Plan"; const firstTask: PlanModelTask = { id: crypto.randomUUID(), title, status: "in_progress", }; - this.session.plan = { title, tasks: [firstTask] }; + this._plan = { title, tasks: [firstTask] }; await this.enqueueEdit(); return { id: firstTask.id, @@ -114,11 +153,11 @@ export class PlanMessageImpl implements PlanMessage { } async addTask(options: AddTaskOptions): Promise { - if (!this.supported) { + if (!this._bound || !this._isSupported()) { return null; } - const title = this.contentToText(options.title) || "Task"; - for (const task of this.session.plan.tasks) { + const title = contentToPlainText(options.title) || "Task"; + for (const task of this._plan.tasks) { if (task.status === "in_progress") { task.status = "complete"; } @@ -129,22 +168,20 @@ export class PlanMessageImpl implements PlanMessage { status: "in_progress", details: options.children, }; - this.session.plan.tasks.push(nextTask); - this.session.plan.title = title; + this._plan.tasks.push(nextTask); + this._plan.title = title; await this.enqueueEdit(); return { id: nextTask.id, title: nextTask.title, status: nextTask.status }; } async updateTask(update?: UpdateTaskInput): Promise { - if (!this.supported) { + if (!this._bound || !this._isSupported()) { return null; } const current = - [...this.session.plan.tasks] - .reverse() - .find((t) => t.status === "in_progress") ?? - this.session.plan.tasks.at(-1); + [...this._plan.tasks].reverse().find((t) => t.status === "in_progress") ?? + this._plan.tasks.at(-1); if (!current) { return null; @@ -166,41 +203,41 @@ export class PlanMessageImpl implements PlanMessage { } async complete(options: CompletePlanOptions): Promise { - if (!this.supported) { + if (!this._bound || !this._isSupported()) { return; } - for (const task of this.session.plan.tasks) { + for (const task of this._plan.tasks) { if (task.status === "in_progress") { task.status = "complete"; } } - this.session.plan.title = - this.contentToText(options.completeMessage) || this.session.plan.title; + this._plan.title = + contentToPlainText(options.completeMessage) || this._plan.title; await this.enqueueEdit(); } - private contentToText(content: PlanContent | undefined): string { - return contentToPlainText(content); - } - private enqueueEdit(): Promise { - const editPlan = this.adapter.editPlan; + if (!this._bound) { + return Promise.resolve(); + } + const editPlan = this._bound.adapter.editPlan; if (!editPlan) { return Promise.resolve(); } + const bound = this._bound; const doEdit = async (): Promise => { await editPlan.call( - this.adapter, - this.session.threadIdForEdits, - this.session.messageId, - this.session.plan + bound.adapter, + bound.threadIdForEdits, + bound.messageId, + this._plan ); }; - const chained = this.session.updateChain.then(doEdit, doEdit); - this.session.updateChain = chained.then( + const chained = bound.updateChain.then(doEdit, doEdit); + bound.updateChain = chained.then( () => undefined, (err) => { - console.warn("[PlanMessage] Failed to edit plan:", err); + console.warn("[Plan] Failed to edit plan:", err); } ); return chained; diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index 63628913..55be9748 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -4,6 +4,7 @@ import { createMockState, createTestMessage, } from "./mock-adapter"; +import { Plan } from "./plan"; import { ThreadImpl } from "./thread"; import type { Adapter, Message } from "./types"; @@ -1113,7 +1114,7 @@ describe("ThreadImpl", () => { // AdapterPostableMessage | CardJSXElement which excludes AsyncIterable }); -describe("postPlan", () => { +describe("post with Plan", () => { let thread: ThreadImpl; let mockAdapter: Adapter; let mockState: ReturnType; @@ -1132,7 +1133,8 @@ describe("postPlan", () => { it("should return no-op PlanMessage when adapter does not support plans", async () => { // Adapter has no postPlan/editPlan methods by default - const plan = await thread.postPlan({ initialMessage: "Starting..." }); + const plan = new Plan({ initialMessage: "Starting..." }) + await thread.post(plan); // Should still return a PlanMessage with inspectors working expect(plan.title()).toBe("Starting..."); @@ -1162,7 +1164,7 @@ describe("postPlan", () => { mockAdapter.postPlan = mockPostPlan; mockAdapter.editPlan = mockEditPlan; - const plan = await thread.postPlan({ initialMessage: "Working..." }); + const plan = await thread.post(new Plan({ initialMessage: "Working..." })); expect(mockPostPlan).toHaveBeenCalledWith( "slack:C123:1234.5678", @@ -1188,7 +1190,8 @@ describe("postPlan", () => { mockAdapter.postPlan = mockPostPlan; mockAdapter.editPlan = mockEditPlan; - const plan = await thread.postPlan({ initialMessage: "Starting" }); + const plan = new Plan({ initialMessage: "Starting" }); + await thread.post(plan); const task = await plan.addTask({ title: "Fetch data", children: ["Call API", "Parse response"], @@ -1213,7 +1216,8 @@ describe("postPlan", () => { mockAdapter.postPlan = mockPostPlan; mockAdapter.editPlan = mockEditPlan; - const plan = await thread.postPlan({ initialMessage: "Working" }); + const plan = new Plan({ initialMessage: "Working" }); + await thread.post(plan); await plan.addTask({ title: "Step 1" }); const updated = await plan.updateTask("Got result: 42"); @@ -1230,7 +1234,8 @@ describe("postPlan", () => { mockAdapter.postPlan = mockPostPlan; mockAdapter.editPlan = mockEditPlan; - const plan = await thread.postPlan({ initialMessage: "Starting" }); + const plan = new Plan({ initialMessage: "Starting" }); + await thread.post(plan); await plan.addTask({ title: "Task 1" }); await plan.complete({ completeMessage: "All done!" }); @@ -1250,7 +1255,8 @@ describe("postPlan", () => { mockAdapter.postPlan = mockPostPlan; mockAdapter.editPlan = mockEditPlan; - const plan = await thread.postPlan({ initialMessage: "First run" }); + const plan = new Plan({ initialMessage: "First run" }); + await thread.post(plan); await plan.addTask({ title: "Task A" }); await plan.addTask({ title: "Task B" }); @@ -1272,7 +1278,8 @@ describe("postPlan", () => { mockAdapter.postPlan = mockPostPlan; mockAdapter.editPlan = mockEditPlan; - const plan = await thread.postPlan({ initialMessage: "Start" }); + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); // Initially, current task is the first one let current = plan.currentTask(); @@ -1302,15 +1309,18 @@ describe("postPlan", () => { mockAdapter.editPlan = mockEditPlan; // String - let plan = await thread.postPlan({ initialMessage: "Simple string" }); + let plan = new Plan({ initialMessage: "Simple string" }); + await thread.post(plan); expect(plan.title()).toBe("Simple string"); // Array of strings - plan = await thread.postPlan({ initialMessage: ["Line 1", "Line 2"] }); + plan = new Plan({ initialMessage: ["Line 1", "Line 2"] }); + await thread.post(plan); expect(plan.title()).toBe("Line 1 Line 2"); // Empty string defaults to "Plan" - plan = await thread.postPlan({ initialMessage: "" }); + plan = new Plan({ initialMessage: "" }); + await thread.post(plan); expect(plan.title()).toBe("Plan"); }); @@ -1331,7 +1341,7 @@ describe("postPlan", () => { mockAdapter.postPlan = mockPostPlan; mockAdapter.editPlan = mockEditPlan; - const plan = await thread.postPlan({ initialMessage: "Start" }); + const plan = await thread.post(new Plan({ initialMessage: "Start" })); // Fire off multiple updates concurrently await Promise.all([ diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index 86c01010..36f94eb8 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -12,7 +12,7 @@ import { toPlainText, } from "./markdown"; import { Message, type SerializedMessage } from "./message"; -import { contentToPlainText, PlanMessageImpl } from "./plan"; +import { isPlan, Plan } from "./plan"; import { StreamingMarkdownRenderer } from "./streaming-markdown"; import type { Adapter, @@ -22,12 +22,9 @@ import type { Channel, EphemeralMessage, PlanMessage, - PlanModel, - PlanModelTask, PostableMessage, PostEphemeralOptions, SentMessage, - StartPlanOptions, StateAdapter, StreamOptions, Thread, @@ -334,7 +331,12 @@ export class ThreadImpl> async post( message: string | PostableMessage | CardJSXElement - ): Promise { + ): Promise { + // Handle Plan objects + if (isPlan(message)) { + return this.handlePlanPost(message); + } + // Handle AsyncIterable (streaming) if (isAsyncIterable(message)) { return this.handleStream(message); @@ -364,6 +366,20 @@ export class ThreadImpl> return result; } + private async handlePlanPost(plan: Plan): Promise { + const adapter = this.adapter; + + if (adapter.postPlan && adapter.editPlan) { + const raw = await adapter.postPlan(this.id, plan._toModel()); + const threadIdForEdits = raw.threadId ?? this.id; + plan._bind(adapter, this.id, raw.id, threadIdForEdits); + } else { + plan._bind(adapter, this.id, `plan_${crypto.randomUUID()}`, this.id); + } + + return plan; + } + async postEphemeral( user: string | Author, message: AdapterPostableMessage | CardJSXElement, @@ -582,41 +598,6 @@ export class ThreadImpl> this._recentMessages = result.messages; } - async postPlan(options: StartPlanOptions): Promise { - const adapter = this.adapter; - const postPlan = adapter.postPlan; - const editPlan = adapter.editPlan; - - const title = contentToPlainText(options.initialMessage) || "Plan"; - const firstTask: PlanModelTask = { - id: crypto.randomUUID(), - title, - status: "in_progress", - }; - const plan: PlanModel = { title, tasks: [firstTask] }; - - if (!(postPlan && editPlan)) { - return new PlanMessageImpl({ - adapter, - supported: false, - threadId: this.id, - messageId: `plan_${crypto.randomUUID()}`, - threadIdForEdits: this.id, - plan, - }); - } - const raw = await postPlan.call(adapter, this.id, plan); - const threadIdForEdits = raw.threadId ?? this.id; - return new PlanMessageImpl({ - adapter, - supported: true, - threadId: threadIdForEdits, - messageId: raw.id, - threadIdForEdits, - plan, - }); - } - mentionUser(userId: string): string { return `<@${userId}>`; } diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 75dd7695..7b88d99f 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -8,6 +8,7 @@ import type { CardJSXElement } from "./jsx-runtime"; import type { Logger, LogLevel } from "./logger"; import type { Message } from "./message"; import type { ModalElement } from "./modals"; +import type { Plan } from "./plan"; // ============================================================================= // Re-exports from extracted modules @@ -556,7 +557,7 @@ export interface Postable< */ post( message: string | PostableMessage | CardJSXElement - ): Promise>; + ): Promise | PlanMessage>; /** * Post an ephemeral message visible only to a specific user. @@ -742,11 +743,17 @@ export interface Thread, TRawMessage = unknown> * // Stream from AI SDK * const result = await agent.stream({ prompt: message.text }); * await thread.post(result.textStream); + * + * // Plan with live updates + * const plan = new Plan({ initialMessage: "Working..." }); + * await thread.post(plan); + * await plan.addTask({ title: "Step 1" }); + * await plan.complete({ completeMessage: "Done!" }); * ``` */ post( message: string | PostableMessage | CardJSXElement - ): Promise>; + ): Promise | PlanMessage>; /** * Post an ephemeral message visible only to a specific user. @@ -782,14 +789,6 @@ export interface Thread, TRawMessage = unknown> options: PostEphemeralOptions ): Promise; - /** - * Post a new plan message in this thread. - * - * Platforms that don't support native plan surfaces will return a PlanMessage - * that no-ops (v1 behavior). - */ - postPlan(options: StartPlanOptions): Promise; - /** Recently fetched messages (cached) */ recentMessages: Message[]; @@ -1110,7 +1109,10 @@ export type AdapterPostableMessage = * - `CardElement` - Direct card element * - `AsyncIterable` - Streaming text (e.g., from AI SDK's textStream) */ -export type PostableMessage = AdapterPostableMessage | AsyncIterable; +export type PostableMessage = + | AdapterPostableMessage + | AsyncIterable + | Plan; export interface PostableRaw { /** File/image attachments */ From 76e2124b504ad0499dd830191502759c85c4ae1d Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Tue, 3 Mar 2026 16:06:33 -0800 Subject: [PATCH 4/9] fixes --- packages/chat/src/channel.ts | 4 ++++ packages/chat/src/thread.ts | 4 ++++ packages/chat/src/types.ts | 10 ++++++---- packages/integration-tests/src/slack.test.ts | 5 +++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/chat/src/channel.ts b/packages/chat/src/channel.ts index a0db33b5..1d65dd72 100644 --- a/packages/chat/src/channel.ts +++ b/packages/chat/src/channel.ts @@ -241,6 +241,10 @@ export class ChannelImpl> }; } + async post(message: Plan): Promise; + async post( + message: string | AdapterPostableMessage | AsyncIterable | CardJSXElement + ): Promise; async post( message: string | PostableMessage | CardJSXElement ): Promise { diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index 36f94eb8..b217fbe0 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -329,6 +329,10 @@ export class ThreadImpl> await this._stateAdapter.unsubscribe(this.id); } + async post(message: Plan): Promise; + async post( + message: string | AdapterPostableMessage | AsyncIterable | CardJSXElement + ): Promise; async post( message: string | PostableMessage | CardJSXElement ): Promise { diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 7b88d99f..51c6dd81 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -555,9 +555,10 @@ export interface Postable< /** * Post a message. */ + post(message: Plan): Promise; post( - message: string | PostableMessage | CardJSXElement - ): Promise | PlanMessage>; + message: string | AdapterPostableMessage | AsyncIterable | CardJSXElement + ): Promise>; /** * Post an ephemeral message visible only to a specific user. @@ -751,9 +752,10 @@ export interface Thread, TRawMessage = unknown> * await plan.complete({ completeMessage: "Done!" }); * ``` */ + post(message: Plan): Promise; post( - message: string | PostableMessage | CardJSXElement - ): Promise | PlanMessage>; + message: string | AdapterPostableMessage | AsyncIterable | CardJSXElement + ): Promise>; /** * Post an ephemeral message visible only to a specific user. diff --git a/packages/integration-tests/src/slack.test.ts b/packages/integration-tests/src/slack.test.ts index 519c3012..f1f4834d 100644 --- a/packages/integration-tests/src/slack.test.ts +++ b/packages/integration-tests/src/slack.test.ts @@ -1,7 +1,7 @@ import { createHmac } from "node:crypto"; import { createSlackAdapter, type SlackAdapter } from "@chat-adapter/slack"; import { createMemoryState } from "@chat-adapter/state-memory"; -import { Chat, type Logger } from "chat"; +import { Chat, Plan, type Logger } from "chat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMockSlackClient, @@ -456,7 +456,8 @@ describe("Slack Integration", () => { it("should render plan/task blocks and update in-place", async () => { chat.onNewMention(async (thread) => { - const plan = await thread.postPlan({ initialMessage: "Working..." }); + const plan = new Plan({ initialMessage: "Working..." }); + await thread.post(plan); await plan.addTask({ title: "Fetch data", children: ["Call API"], From d42c69421d99ab87b3a429e1debd54ad1d60b7e5 Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Tue, 3 Mar 2026 16:11:10 -0800 Subject: [PATCH 5/9] lint fixes --- packages/chat/src/channel.ts | 8 ++++++-- packages/chat/src/plan.ts | 8 ++++---- packages/chat/src/thread.test.ts | 8 +++++--- packages/chat/src/thread.ts | 8 ++++++-- packages/chat/src/types.ts | 12 ++++++++++-- packages/integration-tests/src/slack.test.ts | 2 +- 6 files changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/chat/src/channel.ts b/packages/chat/src/channel.ts index 1d65dd72..690396e2 100644 --- a/packages/chat/src/channel.ts +++ b/packages/chat/src/channel.ts @@ -10,7 +10,7 @@ import { toPlainText, } from "./markdown"; import { Message } from "./message"; -import { isPlan, Plan } from "./plan"; +import { isPlan, type Plan } from "./plan"; import type { Adapter, AdapterPostableMessage, @@ -243,7 +243,11 @@ export class ChannelImpl> async post(message: Plan): Promise; async post( - message: string | AdapterPostableMessage | AsyncIterable | CardJSXElement + message: + | string + | AdapterPostableMessage + | AsyncIterable + | CardJSXElement ): Promise; async post( message: string | PostableMessage | CardJSXElement diff --git a/packages/chat/src/plan.ts b/packages/chat/src/plan.ts index c5fe473a..6f17b6d2 100644 --- a/packages/chat/src/plan.ts +++ b/packages/chat/src/plan.ts @@ -133,7 +133,7 @@ export class Plan implements PlanMessage { } async reset(options: StartPlanOptions): Promise { - if (!this._bound || !this._isSupported()) { + if (!(this._bound && this._isSupported())) { return null; } @@ -153,7 +153,7 @@ export class Plan implements PlanMessage { } async addTask(options: AddTaskOptions): Promise { - if (!this._bound || !this._isSupported()) { + if (!(this._bound && this._isSupported())) { return null; } const title = contentToPlainText(options.title) || "Task"; @@ -176,7 +176,7 @@ export class Plan implements PlanMessage { } async updateTask(update?: UpdateTaskInput): Promise { - if (!this._bound || !this._isSupported()) { + if (!(this._bound && this._isSupported())) { return null; } const current = @@ -203,7 +203,7 @@ export class Plan implements PlanMessage { } async complete(options: CompletePlanOptions): Promise { - if (!this._bound || !this._isSupported()) { + if (!(this._bound && this._isSupported())) { return; } for (const task of this._plan.tasks) { diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index 55be9748..f37b8f51 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -1114,7 +1114,7 @@ describe("ThreadImpl", () => { // AdapterPostableMessage | CardJSXElement which excludes AsyncIterable }); -describe("post with Plan", () => { + describe("post with Plan", () => { let thread: ThreadImpl; let mockAdapter: Adapter; let mockState: ReturnType; @@ -1133,7 +1133,7 @@ describe("post with Plan", () => { it("should return no-op PlanMessage when adapter does not support plans", async () => { // Adapter has no postPlan/editPlan methods by default - const plan = new Plan({ initialMessage: "Starting..." }) + const plan = new Plan({ initialMessage: "Starting..." }); await thread.post(plan); // Should still return a PlanMessage with inspectors working @@ -1164,7 +1164,9 @@ describe("post with Plan", () => { mockAdapter.postPlan = mockPostPlan; mockAdapter.editPlan = mockEditPlan; - const plan = await thread.post(new Plan({ initialMessage: "Working..." })); + const plan = await thread.post( + new Plan({ initialMessage: "Working..." }) + ); expect(mockPostPlan).toHaveBeenCalledWith( "slack:C123:1234.5678", diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index b217fbe0..162bd2cc 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -12,7 +12,7 @@ import { toPlainText, } from "./markdown"; import { Message, type SerializedMessage } from "./message"; -import { isPlan, Plan } from "./plan"; +import { isPlan, type Plan } from "./plan"; import { StreamingMarkdownRenderer } from "./streaming-markdown"; import type { Adapter, @@ -331,7 +331,11 @@ export class ThreadImpl> async post(message: Plan): Promise; async post( - message: string | AdapterPostableMessage | AsyncIterable | CardJSXElement + message: + | string + | AdapterPostableMessage + | AsyncIterable + | CardJSXElement ): Promise; async post( message: string | PostableMessage | CardJSXElement diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 51c6dd81..1016526f 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -557,7 +557,11 @@ export interface Postable< */ post(message: Plan): Promise; post( - message: string | AdapterPostableMessage | AsyncIterable | CardJSXElement + message: + | string + | AdapterPostableMessage + | AsyncIterable + | CardJSXElement ): Promise>; /** @@ -754,7 +758,11 @@ export interface Thread, TRawMessage = unknown> */ post(message: Plan): Promise; post( - message: string | AdapterPostableMessage | AsyncIterable | CardJSXElement + message: + | string + | AdapterPostableMessage + | AsyncIterable + | CardJSXElement ): Promise>; /** diff --git a/packages/integration-tests/src/slack.test.ts b/packages/integration-tests/src/slack.test.ts index f1f4834d..f5d9ac9b 100644 --- a/packages/integration-tests/src/slack.test.ts +++ b/packages/integration-tests/src/slack.test.ts @@ -1,7 +1,7 @@ import { createHmac } from "node:crypto"; import { createSlackAdapter, type SlackAdapter } from "@chat-adapter/slack"; import { createMemoryState } from "@chat-adapter/state-memory"; -import { Chat, Plan, type Logger } from "chat"; +import { Chat, type Logger, Plan } from "chat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMockSlackClient, From fd03adee0ca71f61e44008616ac660c3995d957c Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Tue, 3 Mar 2026 16:39:24 -0800 Subject: [PATCH 6/9] refactor postable objects --- packages/adapter-slack/src/index.ts | 22 +++- packages/chat/src/channel.ts | 40 ++++--- packages/chat/src/index.ts | 5 +- packages/chat/src/plan.ts | 156 +++++++++++++++------------- packages/chat/src/thread.test.ts | 121 +++++++++++---------- packages/chat/src/thread.ts | 39 ++++--- packages/chat/src/types.ts | 87 +++++++++------- 7 files changed, 261 insertions(+), 209 deletions(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 4480ffe0..dd6dcf82 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -1987,13 +1987,19 @@ export class SlackAdapter implements Adapter { } // =========================================================================== - // Plan/Task blocks + // PostableObject (Plan, etc.) support // =========================================================================== - async postPlan( + async postObject( threadId: string, - plan: PlanModel + kind: string, + data: unknown ): Promise> { + if (kind !== "plan") { + throw new Error(`Unsupported postable object kind: ${kind}`); + } + + const plan = data as PlanModel; const { channel, threadTs } = this.decodeThreadId(threadId); const text = this.renderPlanFallbackText(plan); const blocks = this.planToBlockKit(plan); @@ -2021,11 +2027,17 @@ export class SlackAdapter implements Adapter { } } - async editPlan( + async editObject( threadId: string, messageId: string, - plan: PlanModel + kind: string, + data: unknown ): Promise> { + if (kind !== "plan") { + throw new Error(`Unsupported postable object kind: ${kind}`); + } + + const plan = data as PlanModel; const { channel } = this.decodeThreadId(threadId); const text = this.renderPlanFallbackText(plan); const blocks = this.planToBlockKit(plan); diff --git a/packages/chat/src/channel.ts b/packages/chat/src/channel.ts index 690396e2..3a29a233 100644 --- a/packages/chat/src/channel.ts +++ b/packages/chat/src/channel.ts @@ -10,7 +10,7 @@ import { toPlainText, } from "./markdown"; import { Message } from "./message"; -import { isPlan, type Plan } from "./plan"; +import { isPostableObject } from "./plan"; import type { Adapter, AdapterPostableMessage, @@ -18,8 +18,8 @@ import type { Channel, ChannelInfo, EphemeralMessage, - PlanMessage, PostableMessage, + PostableObject, PostEphemeralOptions, SentMessage, StateAdapter, @@ -241,7 +241,7 @@ export class ChannelImpl> }; } - async post(message: Plan): Promise; + async post(message: T): Promise; async post( message: | string @@ -251,10 +251,10 @@ export class ChannelImpl> ): Promise; async post( message: string | PostableMessage | CardJSXElement - ): Promise { - // Handle Plan objects - if (isPlan(message)) { - return this.handlePlanPost(message); + ): Promise { + if (isPostableObject(message)) { + await this.handlePostableObject(message); + return message; } // Handle AsyncIterable (streaming) — not supported at channel level, @@ -283,18 +283,26 @@ export class ChannelImpl> return this.postSingleMessage(postable); } - private async handlePlanPost(plan: Plan): Promise { + private async handlePostableObject(obj: PostableObject): Promise { const adapter = this.adapter; - - if (adapter.postPlan && adapter.editPlan) { - const raw = await adapter.postPlan(this.id, plan._toModel()); - const threadIdForEdits = raw.threadId ?? this.id; - plan._bind(adapter, this.id, raw.id, threadIdForEdits); + if (obj.isSupported(adapter) && adapter.postObject) { + const raw = await adapter.postObject( + this.id, + obj.kind, + obj.getPostData() + ); + obj.onPosted({ + adapter, + messageId: raw.id, + threadId: raw.threadId ?? this.id, + }); } else { - plan._bind(adapter, this.id, `plan_${crypto.randomUUID()}`, this.id); + obj.onPosted({ + adapter, + messageId: `${obj.kind}_${crypto.randomUUID()}`, + threadId: this.id, + }); } - - return plan; } private async postSingleMessage( diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 75b6fc63..0f3d88b8 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -11,7 +11,7 @@ export { type MessageData, type SerializedMessage, } from "./message"; -export { isPlan, Plan } from "./plan"; +export { isPostableObject, Plan } from "./plan"; export { StreamingMarkdownRenderer } from "./streaming-markdown"; export { type SerializedThread, ThreadImpl } from "./thread"; @@ -247,7 +247,6 @@ export type { ModalSubmitHandler, ModalUpdateResponse, PlanContent, - PlanMessage, PlanModel, PlanModelTask, PlanTask, @@ -257,6 +256,8 @@ export type { PostableCard, PostableMarkdown, PostableMessage, + PostableObject, + PostableObjectContext, PostableRaw, PostEphemeralOptions, RawMessage, diff --git a/packages/chat/src/plan.ts b/packages/chat/src/plan.ts index 6f17b6d2..3607aeeb 100644 --- a/packages/chat/src/plan.ts +++ b/packages/chat/src/plan.ts @@ -4,10 +4,11 @@ import type { AddTaskOptions, CompletePlanOptions, PlanContent, - PlanMessage, PlanModel, PlanModelTask, PlanTask, + PostableObject, + PostableObjectContext, StartPlanOptions, UpdateTaskInput, } from "./types"; @@ -34,12 +35,17 @@ export function contentToPlainText(content: PlanContent | undefined): string { return ""; } -const PLAN_TYPE = Symbol.for("chat.plan"); -export function isPlan(value: unknown): value is Plan { +/** Symbol identifying Plan objects */ +const POSTABLE_OBJECT = Symbol.for("chat.postable"); + +/** + * Type guard to check if a value is a PostableObject. + */ +export function isPostableObject(value: unknown): value is PostableObject { return ( typeof value === "object" && value !== null && - (value as Plan).$$typeof === PLAN_TYPE + (value as PostableObject).$$typeof === POSTABLE_OBJECT ); } @@ -47,29 +53,29 @@ interface BoundState { adapter: Adapter; messageId: string; threadId: string; - threadIdForEdits: string; updateChain: Promise; } /** * A Plan represents a task list that can be posted to a thread. * - * Create a plan with `Plan({ initialMessage: "..." })` and post it with `thread.post(plan)`. + * Create a plan with `new Plan({ initialMessage: "..." })` and post it with `thread.post(plan)`. * After posting, use methods like `addTask()`, `updateTask()`, and `complete()` to update it. * * @example * ```typescript - * const plan = Plan({ initialMessage: "Starting task..." }); + * const plan = new Plan({ initialMessage: "Starting task..." }); * await thread.post(plan); * await plan.addTask({ title: "Fetch data" }); * await plan.updateTask("Got 42 results"); * await plan.complete({ completeMessage: "Done!" }); * ``` */ -export class Plan implements PlanMessage { - readonly $$typeof = PLAN_TYPE; +export class Plan implements PostableObject { + readonly $$typeof = POSTABLE_OBJECT; + readonly kind = "plan"; - private _plan: PlanModel; + private _model: PlanModel; private _bound: BoundState | null = null; constructor(options: StartPlanOptions) { @@ -79,85 +85,58 @@ export class Plan implements PlanMessage { title, status: "in_progress", }; - this._plan = { title, tasks: [firstTask] }; + this._model = { title, tasks: [firstTask] }; } - get id(): string { - return this._bound?.messageId ?? ""; + isSupported(adapter: Adapter): boolean { + return !!adapter.postObject && !!adapter.editObject; } - get threadId(): string { - return this._bound?.threadId ?? ""; + getPostData(): PlanModel { + return this._model; } - _bind( - adapter: Adapter, - threadId: string, - messageId: string, - threadIdForEdits: string - ): void { + onPosted(context: PostableObjectContext): void { this._bound = { - adapter, - messageId, - threadId, - threadIdForEdits, + adapter: context.adapter, + messageId: context.messageId, + threadId: context.threadId, updateChain: Promise.resolve(), }; } - _toModel(): PlanModel { - return this._plan; + + get id(): string { + return this._bound?.messageId ?? ""; } - _isSupported(): boolean { - return !!(this._bound?.adapter.postPlan && this._bound?.adapter.editPlan); + get threadId(): string { + return this._bound?.threadId ?? ""; } - - title(): string { - return this._plan.title; + get title(): string { + return this._model.title; } - - tasks(): PlanTask[] { - return this._plan.tasks.map((t) => ({ + get tasks(): readonly PlanTask[] { + return this._model.tasks.map((t) => ({ id: t.id, title: t.title, status: t.status, })); } - - currentTask(): PlanTask | null { + get currentTask(): PlanTask | null { const current = - [...this._plan.tasks].reverse().find((t) => t.status === "in_progress") ?? - this._plan.tasks.at(-1); + [...this._model.tasks] + .reverse() + .find((t) => t.status === "in_progress") ?? this._model.tasks.at(-1); if (!current) { return null; } return { id: current.id, title: current.title, status: current.status }; } - async reset(options: StartPlanOptions): Promise { - if (!(this._bound && this._isSupported())) { - return null; - } - - const title = contentToPlainText(options.initialMessage) || "Plan"; - const firstTask: PlanModelTask = { - id: crypto.randomUUID(), - title, - status: "in_progress", - }; - this._plan = { title, tasks: [firstTask] }; - await this.enqueueEdit(); - return { - id: firstTask.id, - title: firstTask.title, - status: firstTask.status, - }; - } - async addTask(options: AddTaskOptions): Promise { - if (!(this._bound && this._isSupported())) { + if (!this.canMutate()) { return null; } const title = contentToPlainText(options.title) || "Task"; - for (const task of this._plan.tasks) { + for (const task of this._model.tasks) { if (task.status === "in_progress") { task.status = "complete"; } @@ -168,20 +147,21 @@ export class Plan implements PlanMessage { status: "in_progress", details: options.children, }; - this._plan.tasks.push(nextTask); - this._plan.title = title; + this._model.tasks.push(nextTask); + this._model.title = title; await this.enqueueEdit(); return { id: nextTask.id, title: nextTask.title, status: nextTask.status }; } async updateTask(update?: UpdateTaskInput): Promise { - if (!(this._bound && this._isSupported())) { + if (!this.canMutate()) { return null; } const current = - [...this._plan.tasks].reverse().find((t) => t.status === "in_progress") ?? - this._plan.tasks.at(-1); + [...this._model.tasks] + .reverse() + .find((t) => t.status === "in_progress") ?? this._model.tasks.at(-1); if (!current) { return null; @@ -202,35 +182,61 @@ export class Plan implements PlanMessage { return { id: current.id, title: current.title, status: current.status }; } + async reset(options: StartPlanOptions): Promise { + if (!this.canMutate()) { + return null; + } + + const title = contentToPlainText(options.initialMessage) || "Plan"; + const firstTask: PlanModelTask = { + id: crypto.randomUUID(), + title, + status: "in_progress", + }; + this._model = { title, tasks: [firstTask] }; + + await this.enqueueEdit(); + return { + id: firstTask.id, + title: firstTask.title, + status: firstTask.status, + }; + } + async complete(options: CompletePlanOptions): Promise { - if (!(this._bound && this._isSupported())) { + if (!this.canMutate()) { return; } - for (const task of this._plan.tasks) { + for (const task of this._model.tasks) { if (task.status === "in_progress") { task.status = "complete"; } } - this._plan.title = - contentToPlainText(options.completeMessage) || this._plan.title; + this._model.title = + contentToPlainText(options.completeMessage) || this._model.title; await this.enqueueEdit(); } + private canMutate(): boolean { + return !!(this._bound && this.isSupported(this._bound.adapter)); + } + private enqueueEdit(): Promise { if (!this._bound) { return Promise.resolve(); } - const editPlan = this._bound.adapter.editPlan; - if (!editPlan) { + const editObject = this._bound.adapter.editObject; + if (!editObject) { return Promise.resolve(); } const bound = this._bound; const doEdit = async (): Promise => { - await editPlan.call( + await editObject.call( bound.adapter, - bound.threadIdForEdits, + bound.threadId, bound.messageId, - this._plan + this.kind, + this._model ); }; const chained = bound.updateChain.then(doEdit, doEdit); diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index f37b8f51..ad114030 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -1131,15 +1131,13 @@ describe("ThreadImpl", () => { }); }); - it("should return no-op PlanMessage when adapter does not support plans", async () => { - // Adapter has no postPlan/editPlan methods by default + it("should silently no-op when adapter does not support plans", async () => { const plan = new Plan({ initialMessage: "Starting..." }); await thread.post(plan); - // Should still return a PlanMessage with inspectors working - expect(plan.title()).toBe("Starting..."); - expect(plan.tasks()).toHaveLength(1); - expect(plan.tasks()[0].status).toBe("in_progress"); + expect(plan.title).toBe("Starting..."); + expect(plan.tasks).toHaveLength(1); + expect(plan.tasks[0].status).toBe("in_progress"); // Methods should return null (no-op) const task = await plan.addTask({ title: "Task 1" }); @@ -1155,21 +1153,21 @@ describe("ThreadImpl", () => { await plan.complete({ completeMessage: "Done" }); }); - it("should call adapter postPlan when supported", async () => { - const mockPostPlan = vi.fn().mockResolvedValue({ + it("should call adapter postObject when supported", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ id: "plan-msg-1", threadId: "slack:C123:1234.5678", }); - const mockEditPlan = vi.fn().mockResolvedValue(undefined); - mockAdapter.postPlan = mockPostPlan; - mockAdapter.editPlan = mockEditPlan; + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; - const plan = await thread.post( - new Plan({ initialMessage: "Working..." }) - ); + const plan = new Plan({ initialMessage: "Working..." }); + await thread.post(plan); - expect(mockPostPlan).toHaveBeenCalledWith( + expect(mockPostObject).toHaveBeenCalledWith( "slack:C123:1234.5678", + "plan", expect.objectContaining({ title: "Working...", tasks: expect.arrayContaining([ @@ -1183,14 +1181,14 @@ describe("ThreadImpl", () => { expect(plan.id).toBe("plan-msg-1"); }); - it("should add tasks and call editPlan", async () => { - const mockPostPlan = vi.fn().mockResolvedValue({ + it("should add tasks and call editObject", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ id: "plan-msg-1", threadId: "slack:C123:1234.5678", }); - const mockEditPlan = vi.fn().mockResolvedValue(undefined); - mockAdapter.postPlan = mockPostPlan; - mockAdapter.editPlan = mockEditPlan; + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; const plan = new Plan({ initialMessage: "Starting" }); await thread.post(plan); @@ -1202,21 +1200,21 @@ describe("ThreadImpl", () => { expect(task).not.toBeNull(); expect(task?.title).toBe("Fetch data"); expect(task?.status).toBe("in_progress"); - expect(mockEditPlan).toHaveBeenCalled(); + expect(mockEditObject).toHaveBeenCalled(); // Plan title should be updated to current task - expect(plan.title()).toBe("Fetch data"); - expect(plan.tasks()).toHaveLength(2); + expect(plan.title).toBe("Fetch data"); + expect(plan.tasks).toHaveLength(2); }); it("should update current task with output", async () => { - const mockPostPlan = vi.fn().mockResolvedValue({ + const mockPostObject = vi.fn().mockResolvedValue({ id: "plan-msg-1", threadId: "slack:C123:1234.5678", }); - const mockEditPlan = vi.fn().mockResolvedValue(undefined); - mockAdapter.postPlan = mockPostPlan; - mockAdapter.editPlan = mockEditPlan; + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; const plan = new Plan({ initialMessage: "Working" }); await thread.post(plan); @@ -1224,126 +1222,127 @@ describe("ThreadImpl", () => { const updated = await plan.updateTask("Got result: 42"); expect(updated).not.toBeNull(); - expect(mockEditPlan).toHaveBeenCalled(); + expect(mockEditObject).toHaveBeenCalled(); }); it("should complete plan and mark tasks done", async () => { - const mockPostPlan = vi.fn().mockResolvedValue({ + const mockPostObject = vi.fn().mockResolvedValue({ id: "plan-msg-1", threadId: "slack:C123:1234.5678", }); - const mockEditPlan = vi.fn().mockResolvedValue(undefined); - mockAdapter.postPlan = mockPostPlan; - mockAdapter.editPlan = mockEditPlan; + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; const plan = new Plan({ initialMessage: "Starting" }); await thread.post(plan); await plan.addTask({ title: "Task 1" }); await plan.complete({ completeMessage: "All done!" }); - expect(plan.title()).toBe("All done!"); + expect(plan.title).toBe("All done!"); // All tasks should be completed - for (const task of plan.tasks()) { + for (const task of plan.tasks) { expect(task.status).toBe("complete"); } }); it("should reset plan and start fresh", async () => { - const mockPostPlan = vi.fn().mockResolvedValue({ + const mockPostObject = vi.fn().mockResolvedValue({ id: "plan-msg-1", threadId: "slack:C123:1234.5678", }); - const mockEditPlan = vi.fn().mockResolvedValue(undefined); - mockAdapter.postPlan = mockPostPlan; - mockAdapter.editPlan = mockEditPlan; + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; const plan = new Plan({ initialMessage: "First run" }); await thread.post(plan); await plan.addTask({ title: "Task A" }); await plan.addTask({ title: "Task B" }); - expect(plan.tasks()).toHaveLength(3); + expect(plan.tasks).toHaveLength(3); const newTask = await plan.reset({ initialMessage: "Second run" }); expect(newTask).not.toBeNull(); - expect(plan.title()).toBe("Second run"); - expect(plan.tasks()).toHaveLength(1); - expect(plan.tasks()[0].status).toBe("in_progress"); + expect(plan.title).toBe("Second run"); + expect(plan.tasks).toHaveLength(1); + expect(plan.tasks[0].status).toBe("in_progress"); }); it("should return currentTask correctly", async () => { - const mockPostPlan = vi.fn().mockResolvedValue({ + const mockPostObject = vi.fn().mockResolvedValue({ id: "plan-msg-1", threadId: "slack:C123:1234.5678", }); - const mockEditPlan = vi.fn().mockResolvedValue(undefined); - mockAdapter.postPlan = mockPostPlan; - mockAdapter.editPlan = mockEditPlan; + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; const plan = new Plan({ initialMessage: "Start" }); await thread.post(plan); // Initially, current task is the first one - let current = plan.currentTask(); + let current = plan.currentTask; expect(current?.title).toBe("Start"); expect(current?.status).toBe("in_progress"); // After adding a new task, current should be the new one await plan.addTask({ title: "Step 2" }); - current = plan.currentTask(); + current = plan.currentTask; expect(current?.title).toBe("Step 2"); expect(current?.status).toBe("in_progress"); // After completion, currentTask returns the last task await plan.complete({ completeMessage: "Done" }); - current = plan.currentTask(); + current = plan.currentTask; expect(current?.title).toBe("Step 2"); expect(current?.status).toBe("complete"); }); it("should handle various PlanContent formats in initialMessage", async () => { - const mockPostPlan = vi.fn().mockResolvedValue({ + const mockPostObject = vi.fn().mockResolvedValue({ id: "plan-msg-1", threadId: "slack:C123:1234.5678", }); - const mockEditPlan = vi.fn().mockResolvedValue(undefined); - mockAdapter.postPlan = mockPostPlan; - mockAdapter.editPlan = mockEditPlan; + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; // String let plan = new Plan({ initialMessage: "Simple string" }); await thread.post(plan); - expect(plan.title()).toBe("Simple string"); + expect(plan.title).toBe("Simple string"); // Array of strings plan = new Plan({ initialMessage: ["Line 1", "Line 2"] }); await thread.post(plan); - expect(plan.title()).toBe("Line 1 Line 2"); + expect(plan.title).toBe("Line 1 Line 2"); // Empty string defaults to "Plan" plan = new Plan({ initialMessage: "" }); await thread.post(plan); - expect(plan.title()).toBe("Plan"); + expect(plan.title).toBe("Plan"); }); it("should ensure sequential edits via queue", async () => { const editOrder: number[] = []; let editCount = 0; - const mockPostPlan = vi.fn().mockResolvedValue({ + const mockPostObject = vi.fn().mockResolvedValue({ id: "plan-msg-1", threadId: "slack:C123:1234.5678", }); - const mockEditPlan = vi.fn().mockImplementation(async () => { + const mockEditObject = vi.fn().mockImplementation(async () => { const myOrder = ++editCount; // Simulate varying async delays await new Promise((r) => setTimeout(r, Math.random() * 10)); editOrder.push(myOrder); }); - mockAdapter.postPlan = mockPostPlan; - mockAdapter.editPlan = mockEditPlan; + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; - const plan = await thread.post(new Plan({ initialMessage: "Start" })); + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); // Fire off multiple updates concurrently await Promise.all([ diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index 162bd2cc..e7b9051b 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -12,7 +12,7 @@ import { toPlainText, } from "./markdown"; import { Message, type SerializedMessage } from "./message"; -import { isPlan, type Plan } from "./plan"; +import { isPostableObject } from "./plan"; import { StreamingMarkdownRenderer } from "./streaming-markdown"; import type { Adapter, @@ -21,8 +21,8 @@ import type { Author, Channel, EphemeralMessage, - PlanMessage, PostableMessage, + PostableObject, PostEphemeralOptions, SentMessage, StateAdapter, @@ -329,7 +329,7 @@ export class ThreadImpl> await this._stateAdapter.unsubscribe(this.id); } - async post(message: Plan): Promise; + async post(message: T): Promise; async post( message: | string @@ -339,10 +339,10 @@ export class ThreadImpl> ): Promise; async post( message: string | PostableMessage | CardJSXElement - ): Promise { - // Handle Plan objects - if (isPlan(message)) { - return this.handlePlanPost(message); + ): Promise { + if (isPostableObject(message)) { + await this.handlePostableObject(message); + return message; } // Handle AsyncIterable (streaming) @@ -374,18 +374,27 @@ export class ThreadImpl> return result; } - private async handlePlanPost(plan: Plan): Promise { + private async handlePostableObject(obj: PostableObject): Promise { const adapter = this.adapter; - if (adapter.postPlan && adapter.editPlan) { - const raw = await adapter.postPlan(this.id, plan._toModel()); - const threadIdForEdits = raw.threadId ?? this.id; - plan._bind(adapter, this.id, raw.id, threadIdForEdits); + if (obj.isSupported(adapter) && adapter.postObject) { + const raw = await adapter.postObject( + this.id, + obj.kind, + obj.getPostData() + ); + obj.onPosted({ + adapter, + messageId: raw.id, + threadId: raw.threadId ?? this.id, + }); } else { - plan._bind(adapter, this.id, `plan_${crypto.randomUUID()}`, this.id); + obj.onPosted({ + adapter, + messageId: `${obj.kind}_${crypto.randomUUID()}`, + threadId: this.id, + }); } - - return plan; } async postEphemeral( diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 1016526f..015d21b4 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -8,7 +8,6 @@ import type { CardJSXElement } from "./jsx-runtime"; import type { Logger, LogLevel } from "./logger"; import type { Message } from "./message"; import type { ModalElement } from "./modals"; -import type { Plan } from "./plan"; // ============================================================================= // Re-exports from extracted modules @@ -127,13 +126,19 @@ export interface Adapter { ): Promise>; /** - * Optional: edit a previously posted plan message. - * If not implemented, plan helpers will no-op. + * Edit a previously posted object (Plan, Poll, etc.). + * If not implemented, object updates will throw PlanNotSupportedError. + * + * @param threadId - The thread containing the message + * @param messageId - The message ID to edit + * @param kind - The object kind (e.g., "plan") + * @param data - The object data (type depends on kind) */ - editPlan?( + editObject?( threadId: string, messageId: string, - plan: PlanModel + kind: string, + data: unknown ): Promise>; /** Encode platform-specific data into a thread ID string */ @@ -300,12 +305,17 @@ export interface Adapter { ): Promise>; /** - * Optional: post a plan/tasks surface as a single message. - * If not implemented, plan helpers will no-op. + * Post a special object (Plan, Poll, etc.) as a single message. + * If not implemented, posting such objects will throw PlanNotSupportedError. + * + * @param threadId - The thread to post to + * @param kind - The object kind (e.g., "plan") + * @param data - The object data (type depends on kind) */ - postPlan?( + postObject?( threadId: string, - plan: PlanModel + kind: string, + data: unknown ): Promise>; /** Remove a reaction from a message */ @@ -555,7 +565,7 @@ export interface Postable< /** * Post a message. */ - post(message: Plan): Promise; + post(message: T): Promise; post( message: | string @@ -756,7 +766,7 @@ export interface Thread, TRawMessage = unknown> * await plan.complete({ completeMessage: "Done!" }); * ``` */ - post(message: Plan): Promise; + post(message: T): Promise; post( message: | string @@ -841,6 +851,36 @@ export interface Thread, TRawMessage = unknown> unsubscribe(): Promise; } +// ============================================================================= +// Postable Objects +// ============================================================================= + +/** + * Context provided to a PostableObject after it has been posted. + */ +export interface PostableObjectContext { + adapter: Adapter; + messageId: string; + threadId: string; +} + +export interface PostableObject { + /** Symbol identifying this as a postable object */ + readonly $$typeof: symbol; + + /** Get the data to send to the adapter */ + getPostData(): unknown; + + /** Check if the adapter supports this object type */ + isSupported(adapter: Adapter): boolean; + + /** The kind of object - used by adapters to dispatch */ + readonly kind: string; + + /** Called after successful posting to bind the object to the thread */ + onPosted(context: PostableObjectContext): void; +} + export type PlanTaskStatus = "pending" | "in_progress" | "complete" | "error"; export interface PlanTask { @@ -849,29 +889,6 @@ export interface PlanTask { title: string; } -export interface PlanMessage { - /** Add a task and set it in progress. */ - addTask(options: AddTaskOptions): Promise; - /** Complete the plan and mark the current task complete. */ - complete(options: CompletePlanOptions): Promise; - /** Returns the in-progress task, or the last task if none is in-progress. */ - currentTask(): PlanTask | null; - /** The underlying message ID on the platform (or a synthetic ID if unsupported). */ - id: string; - - /** Reset the plan contents and overwrite the same message. */ - reset(options: StartPlanOptions): Promise; - /** All tasks. */ - tasks(): PlanTask[]; - /** Thread ID where the plan was posted. */ - threadId: string; - - /** Current plan title. */ - title(): string; - /** Update the current task (typically output). */ - updateTask(update?: UpdateTaskInput): Promise; -} - export interface PlanModel { tasks: PlanModelTask[]; title: string; @@ -1122,7 +1139,7 @@ export type AdapterPostableMessage = export type PostableMessage = | AdapterPostableMessage | AsyncIterable - | Plan; + | PostableObject; export interface PostableRaw { /** File/image attachments */ From c3eb76299b86414ede6c87d10ec32afaa5b1d604 Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Tue, 3 Mar 2026 16:54:36 -0800 Subject: [PATCH 7/9] knip --- packages/chat/src/plan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/chat/src/plan.ts b/packages/chat/src/plan.ts index 3607aeeb..30507937 100644 --- a/packages/chat/src/plan.ts +++ b/packages/chat/src/plan.ts @@ -16,7 +16,7 @@ import type { /** * Convert PlanContent to plain text for titles/labels. */ -export function contentToPlainText(content: PlanContent | undefined): string { +function contentToPlainText(content: PlanContent | undefined): string { if (!content) { return ""; } From 1cb01d467bc8a5f2779e1686449b9159f893b860 Mon Sep 17 00:00:00 2001 From: Vishal Yathish <135551666+visyat@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:46:35 -0800 Subject: [PATCH 8/9] Refactor PostableObject into dedicated module and add fallback support (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: refactor adapter-slack and types based on Malte's comments Move PostableObject to dedicated file, update type exports, add fallback posts, and make PostableObject generic. Slack-Thread: https://vercel.slack.com/archives/C0977L169MW/p1772755978706959?thread_ts=1772755978.706959&cid=C0977L169MW Co-authored-by: Vishal Yathish <135551666+visyat@users.noreply.github.com> * fix: fallback path for PostableObject and clean up exports - Plan mutations (addTask, updateTask, complete) now work in fallback mode by using editMessage with fallback text instead of no-oping - Revert Slack adapter to throw on unsupported kinds (caller handles fallback, so adapter-level fallback strings are unnecessary) - Remove redundant re-exports of isPostableObject/PostableObject from plan.ts — export from canonical postable-object.ts only - Stop exporting POSTABLE_OBJECT symbol (internal implementation detail) - Fix nested ternary lint error in getFallbackText - Add tests for fallback post/edit path Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: v0 Co-authored-by: Hayden Bleasel Co-authored-by: Claude Opus 4.6 --- packages/chat/src/channel.ts | 11 +- packages/chat/src/index.ts | 31 +++--- packages/chat/src/plan.ts | 146 +++++++++++++++++++-------- packages/chat/src/postable-object.ts | 56 ++++++++++ packages/chat/src/thread.test.ts | 47 +++++++-- packages/chat/src/thread.ts | 9 +- packages/chat/src/types.ts | 95 ++++------------- 7 files changed, 250 insertions(+), 145 deletions(-) create mode 100644 packages/chat/src/postable-object.ts diff --git a/packages/chat/src/channel.ts b/packages/chat/src/channel.ts index 3a29a233..88941016 100644 --- a/packages/chat/src/channel.ts +++ b/packages/chat/src/channel.ts @@ -10,7 +10,7 @@ import { toPlainText, } from "./markdown"; import { Message } from "./message"; -import { isPostableObject } from "./plan"; +import { isPostableObject } from "./postable-object"; import type { Adapter, AdapterPostableMessage, @@ -297,10 +297,15 @@ export class ChannelImpl> threadId: raw.threadId ?? this.id, }); } else { + // Adapter doesn't support this object type - post fallback text + const fallbackText = obj.getFallbackText(); + const raw = this.adapter.postChannelMessage + ? await this.adapter.postChannelMessage(this.id, fallbackText) + : await this.adapter.postMessage(this.id, fallbackText); obj.onPosted({ adapter, - messageId: `${obj.kind}_${crypto.randomUUID()}`, - threadId: this.id, + messageId: raw.id, + threadId: raw.threadId ?? this.id, }); } } diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 0f3d88b8..76a4ad43 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -11,7 +11,23 @@ export { type MessageData, type SerializedMessage, } from "./message"; -export { isPostableObject, Plan } from "./plan"; +export type { + AddTaskOptions, + CompletePlanOptions, + PlanContent, + PlanModel, + PlanModelTask, + PlanTask, + PlanTaskStatus, + StartPlanOptions, + UpdateTaskInput, +} from "./plan"; +export { Plan } from "./plan"; +export type { + PostableObject, + PostableObjectContext, +} from "./postable-object"; +export { isPostableObject } from "./postable-object"; export { StreamingMarkdownRenderer } from "./streaming-markdown"; export { type SerializedThread, ThreadImpl } from "./thread"; @@ -199,13 +215,12 @@ export type { TextInputElement, TextInputOptions, } from "./modals"; -// Types +// Types (Plan types are exported from ./plan, PostableObject types from ./postable-object) export type { ActionEvent, ActionHandler, Adapter, AdapterPostableMessage, - AddTaskOptions, AppHomeOpenedEvent, AppHomeOpenedHandler, AssistantContextChangedEvent, @@ -218,7 +233,6 @@ export type { ChannelInfo, ChatConfig, ChatInstance, - CompletePlanOptions, CustomEmojiMap, Emoji, EmojiFormats, @@ -246,18 +260,11 @@ export type { ModalSubmitEvent, ModalSubmitHandler, ModalUpdateResponse, - PlanContent, - PlanModel, - PlanModelTask, - PlanTask, - PlanTaskStatus, Postable, PostableAst, PostableCard, PostableMarkdown, PostableMessage, - PostableObject, - PostableObjectContext, PostableRaw, PostEphemeralOptions, RawMessage, @@ -266,14 +273,12 @@ export type { SentMessage, SlashCommandEvent, SlashCommandHandler, - StartPlanOptions, StateAdapter, StreamOptions, SubscribedMessageHandler, Thread, ThreadInfo, ThreadSummary, - UpdateTaskInput, WebhookOptions, WellKnownEmoji, } from "./types"; diff --git a/packages/chat/src/plan.ts b/packages/chat/src/plan.ts index 30507937..7a5895a3 100644 --- a/packages/chat/src/plan.ts +++ b/packages/chat/src/plan.ts @@ -1,17 +1,71 @@ +import type { Root } from "mdast"; import { parseMarkdown, toPlainText } from "./markdown"; -import type { - Adapter, - AddTaskOptions, - CompletePlanOptions, - PlanContent, - PlanModel, - PlanModelTask, - PlanTask, - PostableObject, - PostableObjectContext, - StartPlanOptions, - UpdateTaskInput, -} from "./types"; +import { + POSTABLE_OBJECT, + type PostableObject, + type PostableObjectContext, +} from "./postable-object"; +import type { Adapter } from "./types"; + +// ============================================================================= +// Plan Types (moved from types.ts per review feedback) +// ============================================================================= + +export type PlanTaskStatus = "pending" | "in_progress" | "complete" | "error"; + +export interface PlanTask { + id: string; + status: PlanTaskStatus; + title: string; +} + +export interface PlanModel { + tasks: PlanModelTask[]; + title: string; +} + +export interface PlanModelTask { + details?: PlanContent; + id: string; + output?: PlanContent; + status: PlanTaskStatus; + title: string; +} + +export type PlanContent = + | string + | string[] + | { markdown: string } + | { ast: Root }; + +export interface StartPlanOptions { + /** Initial plan title and first task title */ + initialMessage: PlanContent; +} + +export interface AddTaskOptions { + /** Task details/substeps. */ + children?: PlanContent; + title: PlanContent; +} + +export type UpdateTaskInput = + | PlanContent + | { + /** Task output/results. */ + output?: PlanContent; + /** Optional status override. */ + status?: PlanTaskStatus; + }; + +export interface CompletePlanOptions { + /** Final plan title shown when completed */ + completeMessage: PlanContent; +} + +// ============================================================================= +// Plan Implementation +// ============================================================================= /** * Convert PlanContent to plain text for titles/labels. @@ -35,22 +89,9 @@ function contentToPlainText(content: PlanContent | undefined): string { return ""; } -/** Symbol identifying Plan objects */ -const POSTABLE_OBJECT = Symbol.for("chat.postable"); - -/** - * Type guard to check if a value is a PostableObject. - */ -export function isPostableObject(value: unknown): value is PostableObject { - return ( - typeof value === "object" && - value !== null && - (value as PostableObject).$$typeof === POSTABLE_OBJECT - ); -} - interface BoundState { adapter: Adapter; + fallback: boolean; messageId: string; threadId: string; updateChain: Promise; @@ -71,7 +112,7 @@ interface BoundState { * await plan.complete({ completeMessage: "Done!" }); * ``` */ -export class Plan implements PostableObject { +export class Plan implements PostableObject { readonly $$typeof = POSTABLE_OBJECT; readonly kind = "plan"; @@ -91,13 +132,30 @@ export class Plan implements PostableObject { isSupported(adapter: Adapter): boolean { return !!adapter.postObject && !!adapter.editObject; } + getPostData(): PlanModel { return this._model; } + getFallbackText(): string { + const lines: string[] = []; + lines.push(`📋 ${this._model.title || "Plan"}`); + for (const task of this._model.tasks) { + const statusIcons: Record = { + complete: "✅", + in_progress: "🔄", + error: "❌", + }; + const statusIcon = statusIcons[task.status] ?? "⬜"; + lines.push(`${statusIcon} ${task.title}`); + } + return lines.join("\n"); + } + onPosted(context: PostableObjectContext): void { this._bound = { adapter: context.adapter, + fallback: !this.isSupported(context.adapter), messageId: context.messageId, threadId: context.threadId, updateChain: Promise.resolve(), @@ -218,26 +276,34 @@ export class Plan implements PostableObject { } private canMutate(): boolean { - return !!(this._bound && this.isSupported(this._bound.adapter)); + return !!this._bound; } private enqueueEdit(): Promise { if (!this._bound) { return Promise.resolve(); } - const editObject = this._bound.adapter.editObject; - if (!editObject) { - return Promise.resolve(); - } const bound = this._bound; const doEdit = async (): Promise => { - await editObject.call( - bound.adapter, - bound.threadId, - bound.messageId, - this.kind, - this._model - ); + if (bound.fallback) { + await bound.adapter.editMessage( + bound.threadId, + bound.messageId, + this.getFallbackText() + ); + } else { + const editObject = bound.adapter.editObject; + if (!editObject) { + return; + } + await editObject.call( + bound.adapter, + bound.threadId, + bound.messageId, + this.kind, + this._model + ); + } }; const chained = bound.updateChain.then(doEdit, doEdit); bound.updateChain = chained.then( diff --git a/packages/chat/src/postable-object.ts b/packages/chat/src/postable-object.ts new file mode 100644 index 00000000..e59dfbcd --- /dev/null +++ b/packages/chat/src/postable-object.ts @@ -0,0 +1,56 @@ +import type { Adapter } from "./types"; + +/** + * Symbol identifying PostableObject instances. + * Used by type guards to detect postable objects. + */ +export const POSTABLE_OBJECT = Symbol.for("chat.postable"); + +/** + * Context provided to a PostableObject after it has been posted. + */ +export interface PostableObjectContext { + adapter: Adapter; + messageId: string; + threadId: string; +} + +/** + * Base interface for objects that can be posted to threads/channels. + * Examples: Plan, Poll, etc. + * + * @template TData - The data type returned by getPostData() + */ +export interface PostableObject { + /** Symbol identifying this as a postable object */ + readonly $$typeof: symbol; + + /** + * Get a fallback text representation for adapters that don't support this object type. + * This should return a human-readable string representation. + */ + getFallbackText(): string; + + /** Get the data to send to the adapter */ + getPostData(): TData; + + /** Check if the adapter supports this object type */ + isSupported(adapter: Adapter): boolean; + + /** The kind of object - used by adapters to dispatch */ + readonly kind: string; + + /** Called after successful posting to bind the object to the thread */ + onPosted(context: PostableObjectContext): void; +} + +/** + * Type guard to check if a value is a PostableObject. + */ +export function isPostableObject(value: unknown): value is PostableObject { + return ( + typeof value === "object" && + value !== null && + (value as PostableObject).$$typeof === POSTABLE_OBJECT + ); +} diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index ad114030..62e792e7 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -1131,26 +1131,55 @@ describe("ThreadImpl", () => { }); }); - it("should silently no-op when adapter does not support plans", async () => { + it("should post fallback text when adapter does not support plans", async () => { const plan = new Plan({ initialMessage: "Starting..." }); await thread.post(plan); + // Should have posted fallback text via postMessage + expect(mockAdapter.postMessage).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.stringContaining("Starting...") + ); + expect(plan.title).toBe("Starting..."); expect(plan.tasks).toHaveLength(1); expect(plan.tasks[0].status).toBe("in_progress"); + expect(plan.id).toBe("msg-1"); + }); + + it("should update via editMessage in fallback mode", async () => { + const plan = new Plan({ initialMessage: "Starting..." }); + await thread.post(plan); - // Methods should return null (no-op) const task = await plan.addTask({ title: "Task 1" }); - expect(task).toBeNull(); + expect(task).not.toBeNull(); + expect(task?.title).toBe("Task 1"); - const updated = await plan.updateTask("progress"); - expect(updated).toBeNull(); + // Should edit the message with updated fallback text + expect(mockAdapter.editMessage).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "msg-1", + expect.stringContaining("Task 1") + ); + }); - const reset = await plan.reset({ initialMessage: "Reset" }); - expect(reset).toBeNull(); + it("should complete plan via editMessage in fallback mode", async () => { + const plan = new Plan({ initialMessage: "Starting..." }); + await thread.post(plan); - // complete should not throw - await plan.complete({ completeMessage: "Done" }); + await plan.addTask({ title: "Step 1" }); + await plan.complete({ completeMessage: "All done!" }); + + expect(plan.title).toBe("All done!"); + for (const task of plan.tasks) { + expect(task.status).toBe("complete"); + } + + // Last editMessage call should contain completed status icons + const lastCall = ( + mockAdapter.editMessage as ReturnType + ).mock.calls.at(-1); + expect(lastCall?.[2]).toContain("✅"); }); it("should call adapter postObject when supported", async () => { diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index e7b9051b..7f0a1721 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -12,7 +12,7 @@ import { toPlainText, } from "./markdown"; import { Message, type SerializedMessage } from "./message"; -import { isPostableObject } from "./plan"; +import { isPostableObject } from "./postable-object"; import { StreamingMarkdownRenderer } from "./streaming-markdown"; import type { Adapter, @@ -389,10 +389,13 @@ export class ThreadImpl> threadId: raw.threadId ?? this.id, }); } else { + // Adapter doesn't support this object type - post fallback text + const fallbackText = obj.getFallbackText(); + const raw = await this.adapter.postMessage(this.id, fallbackText); obj.onPosted({ adapter, - messageId: `${obj.kind}_${crypto.randomUUID()}`, - threadId: this.id, + messageId: raw.id, + threadId: raw.threadId ?? this.id, }); } } diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 015d21b4..52dfdb26 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -8,6 +8,7 @@ import type { CardJSXElement } from "./jsx-runtime"; import type { Logger, LogLevel } from "./logger"; import type { Message } from "./message"; import type { ModalElement } from "./modals"; +import type { PostableObject } from "./postable-object"; // ============================================================================= // Re-exports from extracted modules @@ -855,83 +856,23 @@ export interface Thread, TRawMessage = unknown> // Postable Objects // ============================================================================= -/** - * Context provided to a PostableObject after it has been posted. - */ -export interface PostableObjectContext { - adapter: Adapter; - messageId: string; - threadId: string; -} - -export interface PostableObject { - /** Symbol identifying this as a postable object */ - readonly $$typeof: symbol; - - /** Get the data to send to the adapter */ - getPostData(): unknown; - - /** Check if the adapter supports this object type */ - isSupported(adapter: Adapter): boolean; - - /** The kind of object - used by adapters to dispatch */ - readonly kind: string; - - /** Called after successful posting to bind the object to the thread */ - onPosted(context: PostableObjectContext): void; -} - -export type PlanTaskStatus = "pending" | "in_progress" | "complete" | "error"; - -export interface PlanTask { - id: string; - status: PlanTaskStatus; - title: string; -} - -export interface PlanModel { - tasks: PlanModelTask[]; - title: string; -} - -export interface PlanModelTask { - details?: PlanContent; - id: string; - output?: PlanContent; - status: PlanTaskStatus; - title: string; -} - -export type PlanContent = - | string - | string[] - | { markdown: string } - | { ast: Root }; - -export interface StartPlanOptions { - /** Initial plan title and first task title */ - initialMessage: PlanContent; -} - -export interface AddTaskOptions { - /** Task details/substeps. */ - children?: PlanContent; - title: PlanContent; -} - -export type UpdateTaskInput = - | PlanContent - | { - /** Task output/results. */ - output?: PlanContent; - /** Optional status override. */ - status?: PlanTaskStatus; - }; - -export interface CompletePlanOptions { - /** Final plan title shown when completed */ - completeMessage: PlanContent; -} +// Re-export Plan types from plan.ts for backwards compatibility +export type { + AddTaskOptions, + CompletePlanOptions, + PlanContent, + PlanModel, + PlanModelTask, + PlanTask, + PlanTaskStatus, + StartPlanOptions, + UpdateTaskInput, +} from "./plan"; +// Re-export PostableObject types from plan.ts for backwards compatibility +export type { + PostableObject, + PostableObjectContext, +} from "./postable-object"; export interface ThreadInfo { channelId: string; From 4e215aaa72954fe8abc5575128ea11bacf76ef14 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Fri, 6 Mar 2026 14:55:21 -0800 Subject: [PATCH 9/9] Fall back to plain text instead of throwing for unsupported postable object kinds Co-Authored-By: Claude Opus 4.6 --- packages/adapter-slack/src/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 57e74768..ee9a9eeb 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -2096,7 +2096,8 @@ export class SlackAdapter implements Adapter { data: unknown ): Promise> { if (kind !== "plan") { - throw new Error(`Unsupported postable object kind: ${kind}`); + // Unsupported kind — post as plain text fallback + return this.postMessage(threadId, `[${kind}]`); } const plan = data as PlanModel; @@ -2134,7 +2135,8 @@ export class SlackAdapter implements Adapter { data: unknown ): Promise> { if (kind !== "plan") { - throw new Error(`Unsupported postable object kind: ${kind}`); + // Unsupported kind — edit as plain text fallback + return this.editMessage(threadId, messageId, `[${kind}]`); } const plan = data as PlanModel;