diff --git a/libs/chat/src/lib/streaming/streaming-markdown.ng0956.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.ng0956.spec.ts
new file mode 100644
index 00000000..49a176e9
--- /dev/null
+++ b/libs/chat/src/lib/streaming/streaming-markdown.ng0956.spec.ts
@@ -0,0 +1,132 @@
+// libs/chat/src/lib/streaming/streaming-markdown.ng0956.spec.ts
+// SPDX-License-Identifier: MIT
+//
+// Regression guard for NG0956 ("tracking expression caused re-creation of the
+// DOM structure") during STREAMING markdown.
+//
+// Why a component test, not an e2e: NG0956 fires only when a tracked `@for`
+// collection RE-MATERIALIZES across change-detection cycles — specifically a
+// (roughly) fixed-length collection whose item identities all churn each cycle,
+// which is exactly what a streaming markdown re-parse produces (the same list /
+// table re-parsed to fresh token objects on every chunk). The aimock e2e
+// replays a message atomically (one `content` snapshot), so the `@for`s render
+// once and never re-evaluate — an e2e console-guard there false-passes. This
+// test drives that re-materialization directly and asserts no NG0956 from the
+// markdown `@for`s (markdown-children + markdown-table use `track $index`).
+//
+// The first test is a NEGATIVE CONTROL proving the capture mechanism actually
+// observes NG0956 in this environment — without it, the assertions below could
+// pass simply because nothing ever emits the warning.
+import { describe, it, expect, beforeEach, vi, type MockInstance } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { Component, signal } from '@angular/core';
+import { ChatStreamingMdComponent } from './streaming-markdown.component';
+
+/** Spy console.warn + console.error; collect any NG0956 messages. */
+function captureNg0956(): { hits: string[]; restore: () => void } {
+ const hits: string[] = [];
+ const sink = (...args: unknown[]): void => {
+ const msg = args.map((a) => String(a)).join(' ');
+ if (msg.includes('NG0956')) hits.push(msg);
+ };
+ const spies: MockInstance[] = [
+ vi.spyOn(console, 'warn').mockImplementation(sink as never),
+ vi.spyOn(console, 'error').mockImplementation(sink as never),
+ ];
+ return { hits, restore: () => spies.forEach((s) => s.mockRestore()) };
+}
+
+// Negative control: a fixed-length `@for` tracked by OBJECT IDENTITY whose items
+// are replaced with fresh refs each cycle — the canonical NG0956 trigger.
+@Component({
+ standalone: true,
+ template: `@for (item of items(); track item) {{{ item.v }}}`,
+})
+class IdentityTrackHost {
+ readonly items = signal<{ v: number }[]>([]);
+ rematerialize(): void {
+ // Same length (3), all-new object refs → identity churn at every position.
+ this.items.set([{ v: 0 }, { v: 1 }, { v: 2 }]);
+ }
+}
+
+// Real subject: the streaming markdown component (markdown-children /
+// markdown-table render their `@for`s with `track $index`).
+@Component({
+ standalone: true,
+ imports: [ChatStreamingMdComponent],
+ template: ``,
+})
+class MarkdownHost {
+ readonly content = signal('');
+ readonly streaming = signal(true);
+}
+
+describe('NG0956 streaming regression guard', () => {
+ describe('negative control (proves NG0956 is observable here)', () => {
+ beforeEach(() => TestBed.configureTestingModule({ imports: [IdentityTrackHost] }));
+
+ it('captures NG0956 when a fixed-length @for tracks by churning identity', () => {
+ const cap = captureNg0956();
+ try {
+ const fixture = TestBed.createComponent(IdentityTrackHost);
+ for (let i = 0; i < 4; i++) {
+ fixture.componentInstance.rematerialize();
+ fixture.detectChanges();
+ }
+ expect(cap.hits.length).toBeGreaterThan(0);
+ } finally {
+ cap.restore();
+ }
+ });
+ });
+
+ describe('streaming markdown emits no NG0956 across re-materialization', () => {
+ beforeEach(() => TestBed.configureTestingModule({ imports: [MarkdownHost] }));
+
+ it('a re-parsing fixed-length list does not warn NG0956', () => {
+ const cap = captureNg0956();
+ try {
+ const fixture = TestBed.createComponent(MarkdownHost);
+ // Each step is a 3-item list with DIFFERENT text → same structure,
+ // fresh token objects on every re-parse → the markdown-children `@for`
+ // genuinely re-materializes. `track $index` must keep NG0956 away.
+ const steps = [
+ '- alpha one\n- beta two\n- gamma three\n',
+ '- alpha ONE\n- beta TWO\n- gamma THREE\n',
+ '- alpha 1\n- beta 2\n- gamma 3\n',
+ '- alpha one!\n- beta two!\n- gamma three!\n',
+ ];
+ for (const s of steps) {
+ fixture.componentInstance.content.set(s);
+ fixture.detectChanges();
+ }
+ expect(cap.hits).toEqual([]);
+ } finally {
+ cap.restore();
+ }
+ });
+
+ it('a re-parsing fixed-size table does not warn NG0956', () => {
+ const cap = captureNg0956();
+ try {
+ const fixture = TestBed.createComponent(MarkdownHost);
+ const table = (a: string, b: string, c: string): string =>
+ `| Name | Value |\n| --- | --- |\n| ${a} | 1 |\n| ${b} | 2 |\n| ${c} | 3 |\n`;
+ const steps = [
+ table('alpha', 'beta', 'gamma'),
+ table('ALPHA', 'BETA', 'GAMMA'),
+ table('a', 'b', 'c'),
+ table('one', 'two', 'three'),
+ ];
+ for (const s of steps) {
+ fixture.componentInstance.content.set(s);
+ fixture.detectChanges();
+ }
+ expect(cap.hits).toEqual([]);
+ } finally {
+ cap.restore();
+ }
+ });
+ });
+});