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;