From a731910eea92dd551a37d4d104b2e7c7271c8ce2 Mon Sep 17 00:00:00 2001 From: v0 Date: Fri, 6 Mar 2026 00:21:46 +0000 Subject: [PATCH 1/2] 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> --- packages/adapter-slack/src/index.ts | 11 ++- packages/chat/src/channel.ts | 11 ++- packages/chat/src/index.ts | 29 ++++--- packages/chat/src/plan.ts | 119 ++++++++++++++++++++------- packages/chat/src/postable-object.ts | 56 +++++++++++++ packages/chat/src/thread.ts | 9 +- packages/chat/src/types.ts | 96 +++++---------------- 7 files changed, 206 insertions(+), 125 deletions(-) create mode 100644 packages/chat/src/postable-object.ts diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index dd6dcf82..f631ba14 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -1995,8 +1995,12 @@ export class SlackAdapter implements Adapter { kind: string, data: unknown ): Promise> { + // Only handle "plan" kind - other kinds should use fallback text via isSupported check if (kind !== "plan") { - throw new Error(`Unsupported postable object kind: ${kind}`); + // Return null to signal unsupported - caller will use fallback + // This shouldn't normally be reached since isSupported is checked first + this.logger.warn("postObject called with unsupported kind", { kind }); + return this.postMessage(threadId, `[Unsupported object: ${kind}]`); } const plan = data as PlanModel; @@ -2033,8 +2037,11 @@ export class SlackAdapter implements Adapter { kind: string, data: unknown ): Promise> { + // Only handle "plan" kind - other kinds should use fallback text via isSupported check if (kind !== "plan") { - throw new Error(`Unsupported postable object kind: ${kind}`); + // This shouldn't normally be reached since isSupported is checked first + this.logger.warn("editObject called with unsupported kind", { kind }); + return this.editMessage(threadId, messageId, `[Unsupported object: ${kind}]`); } const plan = data as PlanModel; 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..2c00e4fc 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -12,6 +12,22 @@ export { type SerializedMessage, } from "./message"; export { isPostableObject, Plan } from "./plan"; +export type { + AddTaskOptions, + CompletePlanOptions, + PlanContent, + PlanModel, + PlanModelTask, + PlanTask, + PlanTaskStatus, + StartPlanOptions, + UpdateTaskInput, +} from "./plan"; +export { POSTABLE_OBJECT } from "./postable-object"; +export type { + PostableObject, + PostableObjectContext, +} 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..f5a18b9c 100644 --- a/packages/chat/src/plan.ts +++ b/packages/chat/src/plan.ts @@ -1,17 +1,76 @@ +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 { + isPostableObject, + POSTABLE_OBJECT, + type PostableObject, + type PostableObjectContext, +} from "./postable-object"; +import type { Adapter } from "./types"; + +// Re-export from postable-object for backwards compatibility +export { isPostableObject }; +export type { PostableObject, PostableObjectContext }; + +// ============================================================================= +// 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,20 +94,6 @@ 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; messageId: string; @@ -71,7 +116,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,10 +136,28 @@ 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 statusIcon = + task.status === "complete" + ? "✅" + : task.status === "in_progress" + ? "🔄" + : task.status === "error" + ? "❌" + : "⬜"; + lines.push(`${statusIcon} ${task.title}`); + } + return lines.join("\n"); + } + onPosted(context: PostableObjectContext): void { this._bound = { adapter: context.adapter, 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.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..004eeef6 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,24 @@ 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 PostableObject types from plan.ts for backwards compatibility +export type { + PostableObject, + PostableObjectContext, +} from "./postable-object"; + +// Re-export Plan types from plan.ts for backwards compatibility +export type { + AddTaskOptions, + CompletePlanOptions, + PlanContent, + PlanModel, + PlanModelTask, + PlanTask, + PlanTaskStatus, + StartPlanOptions, + UpdateTaskInput, +} from "./plan"; export interface ThreadInfo { channelId: string; From 20c3adae0299cc168c6ca520a996c52df8c9fa2a Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Fri, 6 Mar 2026 14:46:05 -0800 Subject: [PATCH 2/2] fix: fallback path for PostableObject and clean up exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/adapter-slack/src/index.ts | 11 ++---- packages/chat/src/index.ts | 4 +-- packages/chat/src/plan.ts | 53 +++++++++++++++-------------- packages/chat/src/thread.test.ts | 47 ++++++++++++++++++++----- packages/chat/src/types.ts | 11 +++--- 5 files changed, 75 insertions(+), 51 deletions(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index f631ba14..dd6dcf82 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -1995,12 +1995,8 @@ export class SlackAdapter implements Adapter { kind: string, data: unknown ): Promise> { - // Only handle "plan" kind - other kinds should use fallback text via isSupported check if (kind !== "plan") { - // Return null to signal unsupported - caller will use fallback - // This shouldn't normally be reached since isSupported is checked first - this.logger.warn("postObject called with unsupported kind", { kind }); - return this.postMessage(threadId, `[Unsupported object: ${kind}]`); + throw new Error(`Unsupported postable object kind: ${kind}`); } const plan = data as PlanModel; @@ -2037,11 +2033,8 @@ export class SlackAdapter implements Adapter { kind: string, data: unknown ): Promise> { - // Only handle "plan" kind - other kinds should use fallback text via isSupported check if (kind !== "plan") { - // This shouldn't normally be reached since isSupported is checked first - this.logger.warn("editObject called with unsupported kind", { kind }); - return this.editMessage(threadId, messageId, `[Unsupported object: ${kind}]`); + throw new Error(`Unsupported postable object kind: ${kind}`); } const plan = data as PlanModel; diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 2c00e4fc..76a4ad43 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -11,7 +11,6 @@ export { type MessageData, type SerializedMessage, } from "./message"; -export { isPostableObject, Plan } from "./plan"; export type { AddTaskOptions, CompletePlanOptions, @@ -23,11 +22,12 @@ export type { StartPlanOptions, UpdateTaskInput, } from "./plan"; -export { POSTABLE_OBJECT } from "./postable-object"; +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"; diff --git a/packages/chat/src/plan.ts b/packages/chat/src/plan.ts index f5a18b9c..7a5895a3 100644 --- a/packages/chat/src/plan.ts +++ b/packages/chat/src/plan.ts @@ -1,17 +1,12 @@ import type { Root } from "mdast"; import { parseMarkdown, toPlainText } from "./markdown"; import { - isPostableObject, POSTABLE_OBJECT, type PostableObject, type PostableObjectContext, } from "./postable-object"; import type { Adapter } from "./types"; -// Re-export from postable-object for backwards compatibility -export { isPostableObject }; -export type { PostableObject, PostableObjectContext }; - // ============================================================================= // Plan Types (moved from types.ts per review feedback) // ============================================================================= @@ -96,6 +91,7 @@ function contentToPlainText(content: PlanContent | undefined): string { interface BoundState { adapter: Adapter; + fallback: boolean; messageId: string; threadId: string; updateChain: Promise; @@ -145,14 +141,12 @@ export class Plan implements PostableObject { const lines: string[] = []; lines.push(`📋 ${this._model.title || "Plan"}`); for (const task of this._model.tasks) { - const statusIcon = - task.status === "complete" - ? "✅" - : task.status === "in_progress" - ? "🔄" - : task.status === "error" - ? "❌" - : "⬜"; + const statusIcons: Record = { + complete: "✅", + in_progress: "🔄", + error: "❌", + }; + const statusIcon = statusIcons[task.status] ?? "⬜"; lines.push(`${statusIcon} ${task.title}`); } return lines.join("\n"); @@ -161,6 +155,7 @@ export class Plan implements PostableObject { onPosted(context: PostableObjectContext): void { this._bound = { adapter: context.adapter, + fallback: !this.isSupported(context.adapter), messageId: context.messageId, threadId: context.threadId, updateChain: Promise.resolve(), @@ -281,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/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/types.ts b/packages/chat/src/types.ts index 004eeef6..52dfdb26 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -856,12 +856,6 @@ export interface Thread, TRawMessage = unknown> // Postable Objects // ============================================================================= -// Re-export PostableObject types from plan.ts for backwards compatibility -export type { - PostableObject, - PostableObjectContext, -} from "./postable-object"; - // Re-export Plan types from plan.ts for backwards compatibility export type { AddTaskOptions, @@ -874,6 +868,11 @@ export type { 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;