diff --git a/libs/chat/package.json b/libs/chat/package.json
index 1c573f90f..bc3e1ac2f 100644
--- a/libs/chat/package.json
+++ b/libs/chat/package.json
@@ -10,7 +10,7 @@
},
"dependencies": {
"@cacheplane/partial-json": ">=0.1.1 <0.3.0",
- "@cacheplane/partial-markdown": "^0.5.4"
+ "@cacheplane/partial-markdown": "^0.5.5"
},
"peerDependencies": {
"zod": "^3.25.0",
diff --git a/libs/chat/src/lib/streaming/streaming-markdown.torture.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.torture.spec.ts
new file mode 100644
index 000000000..6c64124aa
--- /dev/null
+++ b/libs/chat/src/lib/streaming/streaming-markdown.torture.spec.ts
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: MIT
+import { describe, it, expect, beforeEach } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { Component, signal } from '@angular/core';
+import { ChatStreamingMdComponent } from './streaming-markdown.component';
+
+@Component({
+ standalone: true,
+ imports: [ChatStreamingMdComponent],
+ template: ``,
+})
+class HostComponent {
+ content = signal('');
+ streaming = signal(true);
+}
+
+describe('ChatStreamingMdComponent — streaming markdown torture set', () => {
+ let fixture: ReturnType>;
+ let host: HostComponent;
+ let el: HTMLElement;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({ imports: [HostComponent] });
+ fixture = TestBed.createComponent(HostComponent);
+ host = fixture.componentInstance;
+ el = fixture.nativeElement as HTMLElement;
+ });
+
+ const grow = (content: string) => {
+ host.content.set(content);
+ fixture.detectChanges();
+ };
+
+ it('streams list items in one list without raw marker paragraphs', () => {
+ for (const content of ['- alpha', '- alpha\n- be', '- alpha\n- beta\n- gam']) {
+ grow(content);
+ expect(el.querySelectorAll('ul')).toHaveLength(1);
+ expect(
+ [...el.querySelectorAll('p')].some((p) => (p.textContent ?? '').startsWith('- ')),
+ `no raw list marker paragraph at ${JSON.stringify(content)}`,
+ ).toBe(false);
+ }
+ expect([...el.querySelectorAll('li')].map((li) => li.textContent?.trim())).toEqual([
+ 'alpha',
+ 'beta',
+ 'gam',
+ ]);
+ });
+
+ it('streams fenced code as one code block while the closing fence is pending', () => {
+ for (const content of ['```ts\n', '```ts\nconst answer =', '```ts\nconst answer = 42;']) {
+ grow(content);
+ expect(el.querySelectorAll('pre code')).toHaveLength(1);
+ expect(el.querySelector('pre code')?.className).toBe('language-ts');
+ expect(el.querySelector('p')?.textContent ?? '').not.toContain('```');
+ }
+ expect(el.querySelector('pre code')?.textContent).toBe('const answer = 42;');
+ });
+
+ it('streams nested inline formatting without leaking delimiter text', () => {
+ grow('A *em and **strong and `code');
+ expect(el.querySelector('em')?.textContent).toContain('em and');
+ expect(el.querySelector('strong')?.textContent).toContain('strong and code');
+ expect(el.querySelector('strong code')?.textContent).toBe('code');
+ expect(el.textContent).not.toContain('**strong');
+ });
+
+ it('streams blockquote content inside the blockquote', () => {
+ grow('> hello\n> wor');
+ const quote = el.querySelector('blockquote');
+ expect(quote).toBeTruthy();
+ const paragraph = quote?.querySelector('p');
+ expect(paragraph?.textContent).toBe('hellowor');
+ expect(paragraph?.querySelector('br')).toBeTruthy();
+ expect([...el.querySelectorAll('p')].every((p) => quote?.contains(p))).toBe(true);
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index dc8c09e4d..7aa26eb8f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,7 +29,7 @@
"@angular/platform-browser": "~21.1.0",
"@angular/router": "~21.1.0",
"@cacheplane/partial-json": "^0.1.1",
- "@cacheplane/partial-markdown": "^0.5.4",
+ "@cacheplane/partial-markdown": "^0.5.5",
"@langchain/core": "^1.1.33",
"@langchain/langgraph-sdk": "^1.7.4",
"@neondatabase/serverless": "^0.10.0",
@@ -186,12 +186,12 @@
},
"libs/a2ui": {
"name": "@threadplane/a2ui",
- "version": "0.0.53",
+ "version": "0.0.55",
"license": "MIT"
},
"libs/ag-ui": {
"name": "@threadplane/ag-ui",
- "version": "0.0.53",
+ "version": "0.0.55",
"license": "MIT",
"peerDependencies": {
"@ag-ui/client": "*",
@@ -203,11 +203,11 @@
},
"libs/chat": {
"name": "@threadplane/chat",
- "version": "0.0.53",
+ "version": "0.0.55",
"license": "PolyForm-Noncommercial-1.0.0 OR LicenseRef-Threadplane-Commercial",
"dependencies": {
"@cacheplane/partial-json": ">=0.1.1 <0.3.0",
- "@cacheplane/partial-markdown": "^0.5.4"
+ "@cacheplane/partial-markdown": "^0.5.5"
},
"peerDependencies": {
"@angular/common": "^20.0.0 || ^21.0.0",
@@ -338,7 +338,7 @@
},
"libs/langgraph": {
"name": "@threadplane/langgraph",
- "version": "0.0.53",
+ "version": "0.0.55",
"license": "MIT",
"peerDependencies": {
"@angular/core": "^20.0.0 || ^21.0.0",
@@ -350,7 +350,7 @@
},
"libs/licensing": {
"name": "@threadplane/licensing",
- "version": "0.0.53",
+ "version": "0.0.55",
"license": "MIT",
"peerDependencies": {
"@noble/ed25519": "^2.2.3"
@@ -367,7 +367,7 @@
},
"libs/render": {
"name": "@threadplane/render",
- "version": "0.0.53",
+ "version": "0.0.55",
"license": "MIT",
"peerDependencies": {
"@angular/common": "^20.0.0 || ^21.0.0",
@@ -377,7 +377,7 @@
},
"libs/telemetry": {
"name": "@threadplane/telemetry",
- "version": "0.0.53",
+ "version": "0.0.55",
"license": "MIT",
"bin": {
"threadplane-telemetry-postinstall": "node/postinstall.js"
@@ -7272,9 +7272,9 @@
"license": "MIT"
},
"node_modules/@cacheplane/partial-markdown": {
- "version": "0.5.4",
- "resolved": "https://registry.npmjs.org/@cacheplane/partial-markdown/-/partial-markdown-0.5.4.tgz",
- "integrity": "sha512-BUDabdQibR4EsNELxtqPh/fiPVC5igUZiFMv6y+DZYp9u7KvbB9ucal+z4F7y7nOWh6FKmC+BPwMbeHpgLzlbQ==",
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/@cacheplane/partial-markdown/-/partial-markdown-0.5.5.tgz",
+ "integrity": "sha512-jvnM5ARXpozRFl8hvu8+wv3tP6hBYC9Np94Glo4vYEHYjTDUKfx7pW3RY6eTlAHxbin8v7mCWMKDuIVWs12VOA==",
"license": "MIT",
"engines": {
"node": ">=20"
diff --git a/package.json b/package.json
index ac703fc18..ecdfb9661 100644
--- a/package.json
+++ b/package.json
@@ -113,7 +113,7 @@
"@angular/platform-browser": "~21.1.0",
"@angular/router": "~21.1.0",
"@cacheplane/partial-json": "^0.1.1",
- "@cacheplane/partial-markdown": "^0.5.4",
+ "@cacheplane/partial-markdown": "^0.5.5",
"@langchain/core": "^1.1.33",
"@langchain/langgraph-sdk": "^1.7.4",
"@neondatabase/serverless": "^0.10.0",