diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index c8ba8446..ee9a9eeb 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, StreamChunk, @@ -43,8 +44,10 @@ import { defaultEmojiResolver, isJSX, Message, + parseMarkdown, StreamingMarkdownRenderer, toModalElement, + toPlainText, } from "chat"; import { cardToBlockKit, cardToFallbackText } from "./cards"; import type { EncryptedTokenData } from "./crypto"; @@ -2083,6 +2086,188 @@ export class SlackAdapter implements Adapter { } } + // =========================================================================== + // PostableObject (Plan, etc.) support + // =========================================================================== + + async postObject( + threadId: string, + kind: string, + data: unknown + ): Promise> { + if (kind !== "plan") { + // Unsupported kind — post as plain text fallback + return this.postMessage(threadId, `[${kind}]`); + } + + const plan = data as PlanModel; + 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 editObject( + threadId: string, + messageId: string, + kind: string, + data: unknown + ): Promise> { + if (kind !== "plan") { + // Unsupported kind — edit as plain text fallback + return this.editMessage(threadId, messageId, `[${kind}]`); + } + + const plan = data as PlanModel; + 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/channel.ts b/packages/chat/src/channel.ts index 76152f66..7468a45b 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 { isPostableObject } from "./postable-object"; import type { Adapter, AdapterPostableMessage, @@ -18,6 +19,7 @@ import type { ChannelInfo, EphemeralMessage, PostableMessage, + PostableObject, PostEphemeralOptions, SentMessage, StateAdapter, @@ -239,9 +241,22 @@ export class ChannelImpl> }; } + async post(message: T): Promise; + async post( + message: + | string + | AdapterPostableMessage + | AsyncIterable + | ChatElement + ): Promise; async post( message: string | PostableMessage | ChatElement - ): Promise { + ): Promise { + if (isPostableObject(message)) { + await this.handlePostableObject(message); + return message; + } + // Handle AsyncIterable (streaming) — not supported at channel level, // fall through to postMessage if (isAsyncIterable(message)) { @@ -268,6 +283,33 @@ export class ChannelImpl> return this.postSingleMessage(postable); } + private async handlePostableObject(obj: PostableObject): Promise { + const adapter = this.adapter; + 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 { + // 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: raw.id, + threadId: raw.threadId ?? this.id, + }); + } + } + private async postSingleMessage( postable: AdapterPostableMessage ): Promise { diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 9ff654e6..2e9375fe 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -12,6 +12,23 @@ export { type MessageData, type SerializedMessage, } from "./message"; +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"; @@ -241,7 +258,7 @@ export type { TextInputElement, TextInputOptions, } from "./modals"; -// Types +// Types (Plan types are exported from ./plan, PostableObject types from ./postable-object) export type { ActionEvent, ActionHandler, diff --git a/packages/chat/src/plan.ts b/packages/chat/src/plan.ts new file mode 100644 index 00000000..7a5895a3 --- /dev/null +++ b/packages/chat/src/plan.ts @@ -0,0 +1,317 @@ +import type { Root } from "mdast"; +import { parseMarkdown, toPlainText } from "./markdown"; +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. + */ +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 BoundState { + adapter: Adapter; + fallback: boolean; + messageId: string; + threadId: string; + updateChain: Promise; +} + +/** + * A Plan represents a task list that can be posted to a thread. + * + * 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 = 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 PostableObject { + readonly $$typeof = POSTABLE_OBJECT; + readonly kind = "plan"; + + private _model: 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._model = { title, tasks: [firstTask] }; + } + + 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(), + }; + } + + get id(): string { + return this._bound?.messageId ?? ""; + } + get threadId(): string { + return this._bound?.threadId ?? ""; + } + get title(): string { + return this._model.title; + } + get tasks(): readonly PlanTask[] { + return this._model.tasks.map((t) => ({ + id: t.id, + title: t.title, + status: t.status, + })); + } + get currentTask(): PlanTask | null { + const current = + [...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 addTask(options: AddTaskOptions): Promise { + if (!this.canMutate()) { + return null; + } + const title = contentToPlainText(options.title) || "Task"; + for (const task of this._model.tasks) { + if (task.status === "in_progress") { + task.status = "complete"; + } + } + const nextTask: PlanModelTask = { + id: crypto.randomUUID(), + title, + status: "in_progress", + details: options.children, + }; + 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.canMutate()) { + return null; + } + const current = + [...this._model.tasks] + .reverse() + .find((t) => t.status === "in_progress") ?? this._model.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 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.canMutate()) { + return; + } + for (const task of this._model.tasks) { + if (task.status === "in_progress") { + task.status = "complete"; + } + } + this._model.title = + contentToPlainText(options.completeMessage) || this._model.title; + await this.enqueueEdit(); + } + + private canMutate(): boolean { + return !!this._bound; + } + + private enqueueEdit(): Promise { + if (!this._bound) { + return Promise.resolve(); + } + const bound = this._bound; + const doEdit = async (): Promise => { + 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( + () => undefined, + (err) => { + console.warn("[Plan] Failed to edit plan:", err); + } + ); + return chained; + } +} 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 771c6f51..d0c68f4a 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, StreamChunk } from "./types"; @@ -1248,6 +1249,277 @@ describe("ThreadImpl", () => { // AdapterPostableMessage | CardJSXElement which excludes AsyncIterable }); + describe("post with Plan", () => { + 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 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); + + const task = await plan.addTask({ title: "Task 1" }); + expect(task).not.toBeNull(); + expect(task?.title).toBe("Task 1"); + + // Should edit the message with updated fallback text + expect(mockAdapter.editMessage).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "msg-1", + expect.stringContaining("Task 1") + ); + }); + + it("should complete plan via editMessage in fallback mode", async () => { + const plan = new Plan({ initialMessage: "Starting..." }); + await thread.post(plan); + + 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 () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Working..." }); + await thread.post(plan); + + expect(mockPostObject).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "plan", + 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 editObject", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Starting" }); + await thread.post(plan); + 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(mockEditObject).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 mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Working" }); + await thread.post(plan); + await plan.addTask({ title: "Step 1" }); + const updated = await plan.updateTask("Got result: 42"); + + expect(updated).not.toBeNull(); + expect(mockEditObject).toHaveBeenCalled(); + }); + + it("should complete plan and mark tasks done", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + 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!"); + // 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 mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + 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); + + 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 mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + 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; + 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 mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + 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"); + + // Array of strings + 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 = new Plan({ initialMessage: "" }); + await thread.post(plan); + expect(plan.title).toBe("Plan"); + }); + + it("should ensure sequential edits via queue", async () => { + const editOrder: number[] = []; + let editCount = 0; + + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + 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.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); + + // 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]); + }); + }); + describe("subscribe and unsubscribe", () => { let thread: ThreadImpl; let mockAdapter: Adapter; diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index f862734b..952f50aa 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -13,6 +13,7 @@ import { toPlainText, } from "./markdown"; import { Message, type SerializedMessage } from "./message"; +import { isPostableObject } from "./postable-object"; import { StreamingMarkdownRenderer } from "./streaming-markdown"; import type { Adapter, @@ -22,6 +23,7 @@ import type { Channel, EphemeralMessage, PostableMessage, + PostableObject, PostEphemeralOptions, SentMessage, StateAdapter, @@ -332,9 +334,22 @@ export class ThreadImpl> await this._stateAdapter.unsubscribe(this.id); } + async post(message: T): Promise; + async post( + message: + | string + | AdapterPostableMessage + | AsyncIterable + | ChatElement + ): Promise; async post( message: string | PostableMessage | ChatElement - ): Promise { + ): Promise { + if (isPostableObject(message)) { + await this.handlePostableObject(message); + return message; + } + // Handle AsyncIterable (streaming) if (isAsyncIterable(message)) { return this.handleStream(message); @@ -364,6 +379,32 @@ export class ThreadImpl> return result; } + private async handlePostableObject(obj: PostableObject): Promise { + const adapter = this.adapter; + + 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 { + // 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: raw.id, + threadId: raw.threadId ?? this.id, + }); + } + } + async postEphemeral( user: string | Author, message: AdapterPostableMessage | ChatElement, diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 2def01a9..8f1f4927 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -8,6 +8,7 @@ import type { ChatElement } 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 @@ -125,6 +126,22 @@ export interface Adapter { message: AdapterPostableMessage ): Promise>; + /** + * 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) + */ + editObject?( + threadId: string, + messageId: string, + kind: string, + data: unknown + ): Promise>; + /** Encode platform-specific data into a thread ID string */ encodeThreadId(platformData: TThreadId): string; @@ -288,6 +305,20 @@ export interface Adapter { message: AdapterPostableMessage ): Promise>; + /** + * 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) + */ + postObject?( + threadId: string, + kind: string, + data: unknown + ): Promise>; + /** Remove a reaction from a message */ removeReaction( threadId: string, @@ -584,6 +615,7 @@ export interface Postable< /** * Post a message. */ + post(message: T): Promise; post( message: string | PostableMessage | ChatElement ): Promise>; @@ -772,8 +804,15 @@ 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: T): Promise; post( message: string | PostableMessage | ChatElement ): Promise>; @@ -854,6 +893,28 @@ export interface Thread, TRawMessage = unknown> unsubscribe(): Promise; } +// ============================================================================= +// Postable Objects +// ============================================================================= + +// 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; channelName?: string; @@ -1060,7 +1121,8 @@ export type AdapterPostableMessage = */ export type PostableMessage = | AdapterPostableMessage - | AsyncIterable; + | AsyncIterable + | PostableObject; /** * Duck-typed stream event compatible with AI SDK's `fullStream`. diff --git a/packages/integration-tests/src/slack.test.ts b/packages/integration-tests/src/slack.test.ts index c7798030..12b7c52b 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, type Logger, Plan } from "chat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMockSlackClient, @@ -453,6 +453,54 @@ 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 = new Plan({ initialMessage: "Working..." }); + await thread.post(plan); + 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", () => {