diff --git a/.changeset/tidy-bananas-tie.md b/.changeset/tidy-bananas-tie.md new file mode 100644 index 00000000..9f5dda5b --- /dev/null +++ b/.changeset/tidy-bananas-tie.md @@ -0,0 +1,6 @@ +--- +"@chat-adapter/slack": patch +"chat": minor +--- + +Allow Slack native streaming to send markdown tables without wrapping them in code fences, while preserving the previous append-only table fallback for other consumers. diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index b661a348..cc276611 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -2968,7 +2968,9 @@ export class SlackAdapter implements Adapter { let first = true; let lastAppended = ""; - const renderer = new StreamingMarkdownRenderer(); + const renderer = new StreamingMarkdownRenderer({ + wrapTablesForAppend: false, + }); /** * Helper to flush markdown text delta to the stream. diff --git a/packages/chat/src/streaming-markdown.test.ts b/packages/chat/src/streaming-markdown.test.ts index bae652b7..cddced85 100644 --- a/packages/chat/src/streaming-markdown.test.ts +++ b/packages/chat/src/streaming-markdown.test.ts @@ -521,12 +521,15 @@ describe("StreamingMarkdownRenderer", () => { * computing deltas from getCommittableText(), and collecting them. * Returns the concatenated deltas and the final flushed text. */ - function simulateAppendStream(chunks: string[]): { + function simulateAppendStream( + chunks: string[], + options?: ConstructorParameters[0] + ): { appendedText: string; finalText: string; deltas: string[]; } { - const r = new StreamingMarkdownRenderer(); + const r = new StreamingMarkdownRenderer(options); let lastAppended = ""; const deltas: string[] = []; @@ -591,6 +594,26 @@ describe("StreamingMarkdownRenderer", () => { ); }); + it("append-only: table can stream without code fence when wrapping is disabled", () => { + const { appendedText } = simulateAppendStream( + [ + "Intro\n\n", + "| A | B |\n", + "|---|---|\n", + "| 1 | 2 |\n", + "| 3 | 4 |\n", + "\nAfter table\n", + ], + { wrapTablesForAppend: false } + ); + + expect(appendedText).toContain("| A | B |"); + expect(appendedText).toContain("| 1 | 2 |"); + expect(appendedText).toContain("| 3 | 4 |"); + expect(appendedText).toContain("After table"); + expect(appendedText).not.toContain("```"); + }); + it("append-only: table at end of stream is flushed on finish", () => { const { appendedText, deltas } = simulateAppendStream([ "Text\n\n", @@ -626,6 +649,28 @@ describe("StreamingMarkdownRenderer", () => { expect(appendedText).toContain("```"); }); + it("append-only: concatenated deltas equal final text when table wrapping is disabled", () => { + const { appendedText } = simulateAppendStream( + [ + "Hello **world**\n", + "\n", + "| H1 | H2 |\n", + "| - | - |\n", + "| a | b |\n", + "| c | d |\n", + "\nDone\n", + ], + { wrapTablesForAppend: false } + ); + + expect(appendedText).toContain("Hello **world**"); + expect(appendedText).toContain("| H1 | H2 |"); + expect(appendedText).toContain("| a | b |"); + expect(appendedText).toContain("| c | d |"); + expect(appendedText).toContain("Done"); + expect(appendedText).not.toContain("```"); + }); + it("append-only: concatenated deltas are monotonic (each is a suffix)", () => { // This is the core invariant: the concatenated deltas must equal // the final getCommittableText output. This ensures append-only diff --git a/packages/chat/src/streaming-markdown.ts b/packages/chat/src/streaming-markdown.ts index e151464e..3f6633ac 100644 --- a/packages/chat/src/streaming-markdown.ts +++ b/packages/chat/src/streaming-markdown.ts @@ -1,5 +1,13 @@ import remend from "remend"; +interface StreamingMarkdownRendererOptions { + /** + * Wrap confirmed table blocks in code fences for append-only consumers that + * cannot render markdown tables while a stream is in flight. + */ + wrapTablesForAppend?: boolean; +} + /** * A streaming markdown renderer that buffers potential table headers * until confirmed by a separator line, preventing tables from flashing @@ -17,6 +25,13 @@ export class StreamingMarkdownRenderer { private fenceToggles = 0; /** Incomplete trailing line buffer for incremental fence tracking. */ private incompleteLine = ""; + private readonly options: Required; + + constructor(options: StreamingMarkdownRendererOptions = {}) { + this.options = { + wrapTablesForAppend: options.wrapTablesForAppend ?? true, + }; + } /** Append a chunk from the LLM stream. */ push(chunk: string): void { @@ -79,15 +94,16 @@ export class StreamingMarkdownRenderer { * Get text safe for append-only streaming (e.g. Slack native streaming). * * - Holds back unconfirmed table headers until separator arrives. - * - Wraps confirmed tables in code fences so pipes render as literal - * text (not broken mrkdwn). The code fence is left OPEN while - * the table is still streaming, keeping output monotonic for deltas. + * - Optionally wraps confirmed tables in code fences so pipes render as + * literal text on append-only surfaces that lack native table support. + * The code fence is left OPEN while the table is still streaming, + * keeping output monotonic for deltas. * - Holds back unclosed inline markers (**, *, ~~, `, [). * - The final editMessage replaces everything with properly formatted text. */ getCommittableText(): string { if (this.finished) { - return wrapTablesForAppend(this.accumulated, true); + return this.formatAppendOnlyText(this.accumulated, true); } // Strip incomplete last line (no trailing newline) to prevent committing @@ -102,22 +118,23 @@ export class StreamingMarkdownRenderer { // If stripping puts us inside a code fence, keep the incomplete line // (it's likely the closing fence being typed — content is literal). if (isInsideCodeFence(withoutIncompleteLine)) { - // Still wrap preceding tables for consistent coordinate space. - return wrapTablesForAppend(text); + // Still apply any configured table transformation to preserve + // append-only coordinate space for delta calculation. + return this.formatAppendOnlyText(text); } text = withoutIncompleteLine; } // Inside a user code fence: skip table holding and inline marker buffering - // (pipes and markers are literal inside fences), but still wrap preceding - // confirmed tables for consistent coordinate space. + // (pipes and markers are literal inside fences), but still apply any + // configured table transformation to preserve coordinate space. if (isInsideCodeFence(text)) { - return wrapTablesForAppend(text); + return this.formatAppendOnlyText(text); } const committed = getCommittablePrefix(text); - const wrapped = wrapTablesForAppend(committed); + const wrapped = this.formatAppendOnlyText(committed); // If text ends inside an open table code fence, // skip inline marker buffering — markers in code blocks are literal @@ -139,6 +156,13 @@ export class StreamingMarkdownRenderer { this.dirty = true; return this.render(); } + + private formatAppendOnlyText(text: string, closeFences = false): string { + if (!this.options.wrapTablesForAppend) { + return text; + } + return wrapTablesForAppend(text, closeFences); + } } /** @@ -276,8 +300,9 @@ function getCommittablePrefix(text: string): string { /** * Wraps confirmed GFM table blocks in code fences for append-only streaming. * - * Append-only APIs (e.g. Slack streaming) can't render GFM tables natively. - * Wrapping in a code fence makes pipes display as readable literal text. + * Some append-only APIs cannot render GFM tables natively while a stream is + * in flight. Wrapping in a code fence makes pipes display as readable literal + * text until a later full-message render can replace them. * * The code fence is left OPEN if the table is ongoing (no closing ```) * so that output remains monotonic — each new row just extends the block.