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",