` |
+| CRLF line endings | `Line one\r\nLine two\r\n` | both lines present |
+| whitespace only | ` ` | normalized text empty (markdown-it emits a placeholder `` containing whitespace; we only assert the trimmed-text invariant) |
+| empty string | `` | no block elements |
+| trailing whitespace no newline | `Answer ` | `Answer` (trimmed paragraph) |
+
+The "partial bold mid-stream" row exercises the same finalisation path
+PR #290 fixed — the buffer ends mid-token without a trailing newline.
+
+## Target unit 2 — ContentClassifier
+
+**File under test:** [content-classifier.ts](libs/chat/src/lib/streaming/content-classifier.ts)
+
+**New spec:** `libs/chat/src/lib/streaming/content-classifier.variants.spec.ts`
+
+The existing
+[content-classifier.spec.ts](libs/chat/src/lib/streaming/content-classifier.spec.ts)
+covers the happy path for each branch. Variants table targets the
+prefix-detection edge cases — places where the classifier must NOT
+commit prematurely.
+
+**Variance table:**
+
+| Row name | Push sequence | Expected final `type()` |
+| ----------------------------------------- | -------------------------------------- | ----------------------- |
+| single dash | `-` | `pending` |
+| two dashes | `--` | `pending` |
+| three dashes | `---` | `pending` |
+| ---a | `---a` | `pending` |
+| ---a2u | `---a2u` | `pending` |
+| ---a2ui_JSON--- | `---a2ui_JSON---` | `a2ui` |
+| ---a2ui_JSON--- in chunks | `[---, a2u, i_JSON, ---]` | `a2ui` |
+| markdown bullet — leading dash + space | `- bullet` | `markdown` |
+| markdown HR — three dashes + space | `--- horizontal` | `markdown` |
+| dash followed by char not in prefix | `-x` | `markdown` |
+| long dash-led plain text | `-this is just text leading dashes` | `markdown` |
+| leading brace | `{` | `json-render` |
+| leading whitespace then brace | `\n {` | `json-render` |
+| leading whitespace then dash | ` -` | `pending` |
+| empty | `` | `pending` |
+| whitespace only | ` \n ` | `pending` |
+
+Push sequences with multiple chunks call `update()` once per chunk; the
+final assertion uses the classifier's state after the last push.
+
+## Target unit 3 — createPartialArgsBridge
+
+**File under test:** [partial-args-bridge.ts](libs/chat/src/lib/a2ui/partial-args-bridge.ts)
+
+**Existing spec to extend:** [partial-args-bridge.spec.ts](libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts)
+
+The existing spec is already structured per-scenario rather than as a
+table, and several rows from the variance set would duplicate it.
+Phase 1 ADDS a `describe('createPartialArgsBridge — input variance', …)`
+block at the bottom of the same file with `it.each` over the table
+below.
+
+**Variance table:**
+
+| Row name | Pushed args (one per row in the chunks array) | Expected after final push |
+| ---------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------- |
+| open brace then closed brace | `{`, `{}` | no surface mounted, not poisoned |
+| open array mid-stream | `{"envelopes":[` | no surface mounted, not poisoned |
+| escaped quote in component id | full args with id `"with\"quote"` | surface mounted, component id parsed verbatim |
+| trailing whitespace after valid prefix | full args + ` \n ` | surface mounted |
+| unicode in component id | full args with id `"héllo"` | surface mounted, id matches |
+| garbage prefix | `{{{not_json` | poisoned, surfaces empty |
+| valid prefix then garbage suffix | valid args + ` garbage` | poisoned |
+| two tool_call_ids interleaved | push `tc-A` (surface `a`), push `tc-B` (surface `b`) | both surfaces mounted independently |
+| identical chunk pushed twice | full args, full args | exactly one mount, no double dispatch |
+
+The "identical chunk pushed twice" row guards the re-parse path the
+existing tc-6 test uses — but as a single row rather than a hand-written
+case, so future variants slot in cleanly.
+
+> **Char-by-char streams intentionally NOT tested.** A row that fed
+> progressive 1-character prefixes was drafted and dropped during
+> implementation. `@cacheplane/partial-json` materializes partially-parsed
+> strings as their incomplete text (so prefix `"id":"r` materializes as
+> `id: "r"`); the bridge's mount-once gate then synthesises
+> `beginRendering` with `root: "r"` and never re-targets when the id
+> fills in to `"root"`. LLM streams arrive token-chunked, not
+> char-chunked, so this edge case has never bitten production. Phase 1
+> would surface a false positive if it covered it. Filing this as a
+> latent concern for a future phase if char-granular streams ever
+> become real.
+
+## Target unit 4 — createA2uiMessageParser
+
+**File under test:** [parser.ts](libs/a2ui/src/lib/parser.ts)
+
+**Existing spec to extend:** [parser.spec.ts](libs/a2ui/src/lib/parser.spec.ts)
+
+Add a `describe('createA2uiMessageParser — input variance', …)` block
+at the bottom of the existing spec.
+
+**Variance table:**
+
+| Row name | Push sequence | Expected emitted envelopes |
+| -------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------- |
+| envelope with trailing CRLF | `{"beginRendering":{"surfaceId":"s","root":"r"}}\r\n` | 1 beginRendering |
+| envelope split mid-key | `{"begin`, `Rendering":{"surfaceId":"s","root":"r"}}\n` | 1 beginRendering |
+| envelope split mid-string-value | `{"beginRendering":{"surfaceId":"s","root":"`, `r"}}\n` | 1 beginRendering |
+| three envelopes one chunk | concatenated 3 valid JSON lines with `\n` separators | 3 envelopes in order |
+| three envelopes char-by-char | same input fed one character at a time | 3 envelopes in order |
+| malformed line then valid line | `{garbage}\n` + valid envelope `\n` | 1 valid envelope (malformed dropped) |
+| valid envelope, no trailing newline | valid envelope JSON without final `\n` | 0 envelopes (parser waits for delimiter) |
+| valid envelope, then trailing newline later | valid envelope JSON (no `\n`), then push `\n` | 1 envelope after second push |
+| empty lines between envelopes | `\n\n` + valid envelope + `\n\n` + valid envelope + `\n` | 2 envelopes |
+| envelope with whitespace before brace | ` {"beginRendering":...}\n` | 1 envelope |
+| envelope key we don't recognise | `{"mysteryUpdate":{}}\n` | 0 envelopes (skipped) |
+| mixed valid + unrecognised + valid | valid + unknown + valid (each on its own line) | 2 valid envelopes |
+
+The "valid envelope, no trailing newline" row encodes a key invariant:
+the parser is delimiter-driven and is allowed to wait. That contract is
+relied on by the streaming pipeline; if a future refactor "helpfully"
+flushes the last buffered line on its own, this row catches it.
+
+## Test layout conventions
+
+Per Phase 1, every new spec file follows these rules:
+
+1. License header on the first line: `// SPDX-License-Identifier: MIT`
+2. `import { describe, it, expect } from 'vitest'` (use `beforeEach` only
+ when fixture state must be reset per row).
+3. Use `it.each` (or `describe.each` when the assertion shape varies per
+ row) to keep the table at the top of the spec and the assertions in
+ one place at the bottom.
+4. Row names match the table in this spec — easier to triage failures.
+5. Variance specs must NOT duplicate the happy-path coverage in the
+ pre-existing spec for the same unit. If a row is already covered,
+ either remove the duplicate from the existing spec or skip the row.
+
+## Runtime expectations
+
+- `nx run chat:test` adds ~30–40 new assertions; current run time is
+ under 10s and Phase 1 should keep it under 15s.
+- `nx run a2ui:test` adds ~12 new assertions; current run time is under
+ 3s; Phase 1 should keep it under 5s.
+- No new dependencies, no new tooling, no new CI jobs — Phase 1 is
+ additive within the existing `nx run-many --target=test` pipeline.
+
+## Deferred (NOT Phase 1)
+
+- Mock-agent harness for end-to-end streaming tests.
+- AIMock-based E2E coverage that drives the chat composition with
+ scripted SSE frames.
+- A CI workflow split that surfaces Phase 1 failures separately from
+ flaky integration tests.
+- Property-based / fuzz testing of the streaming pipeline (interesting,
+ but doesn't catch the same class of regression PR #290 represented).
diff --git a/libs/a2ui/src/lib/parser.spec.ts b/libs/a2ui/src/lib/parser.spec.ts
index 577554057..d8a2067f5 100644
--- a/libs/a2ui/src/lib/parser.spec.ts
+++ b/libs/a2ui/src/lib/parser.spec.ts
@@ -77,3 +77,53 @@ describe('createA2uiMessageParser (v1)', () => {
expect(msgs).toHaveLength(3);
});
});
+
+interface ParserRow {
+ name: string;
+ /** Sequence of chunks to push. */
+ chunks: readonly string[];
+ /** Expected envelope-key sequence across all push() calls combined. */
+ expectedKeys: readonly string[];
+}
+
+const BR = (root: string) =>
+ JSON.stringify({ beginRendering: { surfaceId: 's', root } });
+const SU = () =>
+ JSON.stringify({ surfaceUpdate: { surfaceId: 's', components: [] } });
+const DM = (key: string) =>
+ JSON.stringify({ dataModelUpdate: { surfaceId: 's', contents: [{ key, valueString: 'v' }] } });
+
+const parserRows: ParserRow[] = [
+ { name: 'envelope with CRLF', chunks: [BR('r') + '\r\n'], expectedKeys: ['beginRendering'] },
+ { name: 'envelope split mid-key', chunks: ['{"begin', 'Rendering":{"surfaceId":"s","root":"r"}}\n'], expectedKeys: ['beginRendering'] },
+ { name: 'envelope split mid-string-value', chunks: ['{"beginRendering":{"surfaceId":"s","root":"', 'r"}}\n'], expectedKeys: ['beginRendering'] },
+ { name: 'three envelopes one chunk', chunks: [[SU(), DM('k'), BR('r')].join('\n') + '\n'], expectedKeys: ['surfaceUpdate', 'dataModelUpdate', 'beginRendering'] },
+ {
+ name: 'three envelopes char-by-char',
+ chunks: ([SU(), DM('k'), BR('r')].join('\n') + '\n').split(''),
+ expectedKeys: ['surfaceUpdate', 'dataModelUpdate', 'beginRendering'],
+ },
+ { name: 'malformed line then valid line', chunks: ['{garbage}\n' + BR('r') + '\n'], expectedKeys: ['beginRendering'] },
+ { name: 'valid envelope no trailing newline waits', chunks: [BR('r')], expectedKeys: [] },
+ { name: 'valid envelope, then trailing newline later', chunks: [BR('r'), '\n'], expectedKeys: ['beginRendering'] },
+ { name: 'empty lines between envelopes', chunks: ['\n\n' + BR('r') + '\n\n' + BR('r2') + '\n'], expectedKeys: ['beginRendering', 'beginRendering'] },
+ { name: 'whitespace before brace', chunks: [' ' + BR('r') + '\n'], expectedKeys: ['beginRendering'] },
+ { name: 'unrecognised envelope key', chunks: ['{"mysteryUpdate":{}}\n'], expectedKeys: [] },
+ {
+ name: 'mixed valid + unknown + valid',
+ chunks: [[BR('r'), '{"mysteryUpdate":{}}', BR('r2')].join('\n') + '\n'],
+ expectedKeys: ['beginRendering', 'beginRendering'],
+ },
+];
+
+describe('createA2uiMessageParser — input variance', () => {
+ test.each(parserRows)('$name', (row) => {
+ const parser = createA2uiMessageParser();
+ const keys: string[] = [];
+ for (const chunk of row.chunks) {
+ const msgs = parser.push(chunk);
+ for (const m of msgs) keys.push(Object.keys(m)[0]);
+ }
+ expect(keys).toEqual(row.expectedKeys);
+ });
+});
diff --git a/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts b/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts
index 30643255a..387fc4391 100644
--- a/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts
+++ b/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts
@@ -157,3 +157,81 @@ describe('createPartialArgsBridge', () => {
expect(beginEnv!.beginRendering.root).toBe('alpha');
});
});
+
+interface BridgeRow {
+ name: string;
+ /** Sequence of (toolCallId, argsSoFar) pushes. */
+ pushes: ReadonlyArray;
+ /** Assertion run after the final push. */
+ assert: (store: A2uiSurfaceStore, bridge: ReturnType) => void;
+}
+
+const SURFACE_S_FULL =
+ '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}]}';
+
+const bridgeRows: BridgeRow[] = [
+ {
+ name: 'open brace then closed brace stays unpoisoned',
+ pushes: [['tc-2', '{'], ['tc-2', '{}']],
+ assert: (store, bridge) => {
+ expect(store.surfaces().size).toBe(0);
+ expect(bridge.isPoisoned('tc-2')).toBe(false);
+ },
+ },
+ {
+ name: 'open envelopes array stays unpoisoned',
+ pushes: [['tc-3', '{"envelopes":[']],
+ assert: (store, bridge) => {
+ expect(store.surfaces().size).toBe(0);
+ expect(bridge.isPoisoned('tc-3')).toBe(false);
+ },
+ },
+ {
+ name: 'trailing whitespace after valid args',
+ pushes: [['tc-4', SURFACE_S_FULL + ' \n ']],
+ assert: (store) => {
+ expect(store.surfaces().get('s')?.components.has('root')).toBe(true);
+ },
+ },
+ {
+ name: 'garbage prefix poisons',
+ pushes: [['tc-5', '{{{not_json']],
+ assert: (_store, bridge) => {
+ expect(bridge.isPoisoned('tc-5')).toBe(true);
+ },
+ },
+ {
+ name: 'valid prefix then garbage suffix poisons',
+ pushes: [['tc-6', SURFACE_S_FULL + ' garbage']],
+ assert: (_store, bridge) => {
+ expect(bridge.isPoisoned('tc-6')).toBe(true);
+ },
+ },
+ {
+ name: 'two tool_call_ids mount independent surfaces',
+ pushes: [
+ ['tc-7a', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"a","components":[{"id":"root","type":"text","props":{}}]}}]}'],
+ ['tc-7b', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"b","components":[{"id":"root","type":"text","props":{}}]}}]}'],
+ ],
+ assert: (store) => {
+ expect(store.surfaces().get('a')?.components.has('root')).toBe(true);
+ expect(store.surfaces().get('b')?.components.has('root')).toBe(true);
+ },
+ },
+ {
+ name: 'identical chunk pushed twice mounts exactly once',
+ pushes: [['tc-8', SURFACE_S_FULL], ['tc-8', SURFACE_S_FULL]],
+ assert: (store) => {
+ expect(store.surfaces().get('s')?.components.size).toBe(1);
+ },
+ },
+];
+
+describe('createPartialArgsBridge — input variance', () => {
+ it.each(bridgeRows)('$name', (row) => {
+ const store = makeStore();
+ const bridge = createPartialArgsBridge(store);
+ for (const [tc, args] of row.pushes) bridge.push(tc, args);
+ row.assert(store, bridge);
+ });
+});
diff --git a/libs/chat/src/lib/streaming/content-classifier.variants.spec.ts b/libs/chat/src/lib/streaming/content-classifier.variants.spec.ts
new file mode 100644
index 000000000..625f26bc6
--- /dev/null
+++ b/libs/chat/src/lib/streaming/content-classifier.variants.spec.ts
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: MIT
+import { describe, it, expect } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { createContentClassifier, type ContentType } from './content-classifier';
+
+interface Row {
+ name: string;
+ pushes: readonly string[];
+ expectedType: ContentType;
+}
+
+const rows: Row[] = [
+ { name: 'single dash', pushes: ['-'], expectedType: 'pending' },
+ { name: 'two dashes', pushes: ['--'], expectedType: 'pending' },
+ { name: 'three dashes', pushes: ['---'], expectedType: 'pending' },
+ { name: '---a', pushes: ['---a'], expectedType: 'pending' },
+ { name: '---a2u', pushes: ['---a2u'], expectedType: 'pending' },
+ { name: '---a2ui_JSON--- single chunk', pushes: ['---a2ui_JSON---'], expectedType: 'a2ui' },
+ { name: '---a2ui_JSON--- in many chunks', pushes: ['---', 'a2u', 'i_JSON', '---'], expectedType: 'a2ui' },
+ { name: 'markdown bullet leading dash space', pushes: ['- bullet'], expectedType: 'markdown' },
+ { name: 'markdown HR three dashes space', pushes: ['--- horizontal'], expectedType: 'markdown' },
+ { name: 'dash followed by non-prefix char', pushes: ['-x'], expectedType: 'markdown' },
+ { name: 'long dash-led plain text', pushes: ['-this is just text leading dashes'], expectedType: 'markdown' },
+ { name: 'leading brace', pushes: ['{'], expectedType: 'json-render' },
+ { name: 'leading whitespace then brace', pushes: ['\n {'], expectedType: 'json-render' },
+ { name: 'leading whitespace then dash', pushes: [' -'], expectedType: 'pending' },
+ { name: 'empty', pushes: [''], expectedType: 'pending' },
+ { name: 'whitespace only', pushes: [' \n '], expectedType: 'pending' },
+];
+
+describe('ContentClassifier — input variance', () => {
+ it.each(rows)('$name', (row) => {
+ TestBed.configureTestingModule({});
+ let type!: ContentType;
+ TestBed.runInInjectionContext(() => {
+ const c = createContentClassifier();
+ let accumulated = '';
+ for (const chunk of row.pushes) {
+ accumulated += chunk;
+ c.update(accumulated);
+ }
+ type = c.type();
+ });
+ expect(type).toBe(row.expectedType);
+ });
+});
diff --git a/libs/chat/src/lib/streaming/streaming-markdown.variants.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.variants.spec.ts
new file mode 100644
index 000000000..a1b82604b
--- /dev/null
+++ b/libs/chat/src/lib/streaming/streaming-markdown.variants.spec.ts
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: MIT
+import { describe, it, expect } 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(false);
+}
+
+interface FinalizedRow {
+ name: string;
+ input: string;
+ /** Expected concatenated textContent of the rendered root, trimmed and collapsed-whitespace. */
+ expectedText: string;
+ /** Optional CSS selector that must match at least once in the rendered DOM. */
+ selectorPresent?: string;
+ /** Optional CSS selector that must NOT match. */
+ selectorAbsent?: string;
+}
+
+const finalizedRows: FinalizedRow[] = [
+ { name: 'plain text no trailing newline', input: 'Hello', expectedText: 'Hello', selectorPresent: 'p' },
+ { name: 'plain text with trailing newline', input: 'Hello\n', expectedText: 'Hello', selectorPresent: 'p' },
+ { name: 'heading no trailing newline', input: '# Title', expectedText: 'Title', selectorPresent: 'h1' },
+ { name: 'heading with trailing newline', input: '# Title\n', expectedText: 'Title', selectorPresent: 'h1' },
+ { name: 'completed bold', input: '**bold**', expectedText: 'bold', selectorPresent: 'strong' },
+ { name: 'inline code', input: 'Run `npm test` to verify', expectedText: 'Run npm test to verify', selectorPresent: 'code' },
+ { name: 'CRLF line endings', input: 'Line one\r\nLine two\r\n', expectedText: 'Line one Line two' },
+ { name: 'whitespace only', input: ' ', expectedText: '' },
+ { name: 'empty string', input: '', expectedText: '', selectorAbsent: 'p' },
+ { name: 'trailing whitespace no newline', input: 'Answer ', expectedText: 'Answer', selectorPresent: 'p' },
+];
+
+function normalize(s: string): string {
+ return s.replace(/\s+/g, ' ').trim();
+}
+
+describe('ChatStreamingMdComponent — finalized input variance', () => {
+ it.each(finalizedRows)('$name', (row) => {
+ TestBed.configureTestingModule({ imports: [HostComponent] });
+ const fixture = TestBed.createComponent(HostComponent);
+ fixture.componentInstance.content.set(row.input);
+ fixture.componentInstance.streaming.set(false);
+ fixture.detectChanges();
+ expect(normalize(fixture.nativeElement.textContent ?? '')).toBe(row.expectedText);
+ if (row.selectorPresent) {
+ expect(fixture.nativeElement.querySelector(row.selectorPresent)).toBeTruthy();
+ }
+ if (row.selectorAbsent) {
+ expect(fixture.nativeElement.querySelector(row.selectorAbsent)).toBeNull();
+ }
+ });
+});
+
+interface MidStreamRow {
+ name: string;
+ /** Content pushed while streaming=true. */
+ midStream: string;
+ /** Content pushed when streaming flips to false. Defaults to midStream. */
+ onFinish?: string;
+ expectedText: string;
+}
+
+const midStreamRows: MidStreamRow[] = [
+ { name: 'partial bold mid-stream then unchanged', midStream: '**bo', expectedText: '**bo' },
+ { name: 'partial bold mid-stream then completed', midStream: '**bo', onFinish: '**bold**', expectedText: 'bold' },
+ { name: 'unfinished sentence then finalized', midStream: 'The quick', onFinish: 'The quick brown fox.', expectedText: 'The quick brown fox.' },
+];
+
+describe('ChatStreamingMdComponent — mid-stream input variance', () => {
+ it.each(midStreamRows)('$name', (row) => {
+ TestBed.configureTestingModule({ imports: [HostComponent] });
+ const fixture = TestBed.createComponent(HostComponent);
+ fixture.componentInstance.content.set(row.midStream);
+ fixture.componentInstance.streaming.set(true);
+ fixture.detectChanges();
+ fixture.componentInstance.content.set(row.onFinish ?? row.midStream);
+ fixture.componentInstance.streaming.set(false);
+ fixture.detectChanges();
+ expect(normalize(fixture.nativeElement.textContent ?? '')).toBe(row.expectedText);
+ });
+});