Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tidy-bananas-tie.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion packages/adapter-slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2968,7 +2968,9 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {

let first = true;
let lastAppended = "";
const renderer = new StreamingMarkdownRenderer();
const renderer = new StreamingMarkdownRenderer({
wrapTablesForAppend: false,
});

/**
* Helper to flush markdown text delta to the stream.
Expand Down
49 changes: 47 additions & 2 deletions packages/chat/src/streaming-markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof StreamingMarkdownRenderer>[0]
): {
appendedText: string;
finalText: string;
deltas: string[];
} {
const r = new StreamingMarkdownRenderer();
const r = new StreamingMarkdownRenderer(options);
let lastAppended = "";
const deltas: string[] = [];

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
49 changes: 37 additions & 12 deletions packages/chat/src/streaming-markdown.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +25,13 @@ export class StreamingMarkdownRenderer {
private fenceToggles = 0;
/** Incomplete trailing line buffer for incremental fence tracking. */
private incompleteLine = "";
private readonly options: Required<StreamingMarkdownRendererOptions>;

constructor(options: StreamingMarkdownRendererOptions = {}) {
this.options = {
wrapTablesForAppend: options.wrapTablesForAppend ?? true,
};
}

/** Append a chunk from the LLM stream. */
push(chunk: string): void {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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);
}
}

/**
Expand Down Expand Up @@ -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.
Expand Down
Loading