Skip to content

Commit 0a7158c

Browse files
authored
Merge pull request #791 from cacheplane/blove/streaming-markdown-e2e-regressions
Filter transcript streaming nodes
2 parents 479a32f + d2b5564 commit 0a7158c

12 files changed

Lines changed: 285 additions & 13 deletions

File tree

apps/website/content/docs/langgraph/api/api-docs.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,12 @@
946946
"description": "Custom message deserializer for non-standard message formats.",
947947
"optional": true
948948
},
949+
{
950+
"name": "transcriptNodeNames",
951+
"type": "string[]",
952+
"description": "LangGraph node names whose `messages-tuple` LLM chunks should be projected\ninto the main chat transcript. Omit to accept all top-level message chunks.",
953+
"optional": true
954+
},
949955
{
950956
"name": "transport",
951957
"type": "AgentTransport",
@@ -1082,6 +1088,12 @@
10821088
"description": "Custom message deserializer for non-standard message formats.",
10831089
"optional": true
10841090
},
1091+
{
1092+
"name": "transcriptNodeNames",
1093+
"type": "string[]",
1094+
"description": "LangGraph node names whose `messages-tuple` LLM chunks should be projected\ninto the main chat transcript. Omit to accept all top-level message chunks.\n\nUse this when a graph has side-effect LLM nodes, such as title generation,\nwhose streamed model output should not render as assistant chat content.",
1095+
"optional": true
1096+
},
10851097
{
10861098
"name": "transport",
10871099
"type": "AgentTransport",

apps/website/content/docs/langgraph/api/provide-agent.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ bootstrapApplication(AppComponent, {
4141
| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. |
4242
| `filterSubagentMessages` | `boolean` | When true, subagent messages are filtered from the main messages signal. |
4343
| `subagentToolNames` | `string[]` | Tool names that indicate a subagent invocation. |
44+
| `transcriptNodeNames` | `string[]` | LangGraph node names whose `messages-tuple` chunks should stream into the main chat transcript. Omit to accept all top-level chunks. |
4445

4546
## Singleton model
4647

@@ -56,6 +57,18 @@ provideAgent({
5657
const chat = injectAgent();
5758
```
5859

60+
## Transcript node filtering
61+
62+
LangGraph streams `messages-tuple` chunks for every LLM node in a run. If your graph has side-effect LLM nodes, such as a title generator or evaluator, set `transcriptNodeNames` so only your conversational node updates `messages()`.
63+
64+
```ts
65+
provideAgent({
66+
apiUrl: 'https://api.example.com',
67+
assistantId: 'support-agent',
68+
transcriptNodeNames: ['generate'],
69+
});
70+
```
71+
5972
## Test transports
6073

6174
`transport` is an object that implements `AgentTransport`, not an Angular class token. Create an instance before passing it to `provideAgent()`.

examples/chat/angular/e2e/aimock-runner.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,9 @@ function loadFixtureEntries(fixturePath: string): FixtureFileEntry[] {
4747
export async function startAimock(opts: AimockStartOptions): Promise<AimockHandle> {
4848
const entries = loadFixtureEntries(opts.fixturePath);
4949

50-
// Use a large chunkSize so each response arrives in 1-2 SSE deltas. This
51-
// intentionally turns off the partial-markdown streaming path for harness
52-
// tests: structural assertions (code fence, list) measure the FINAL rendered
53-
// DOM, not the progressive render. With aggressive default chunking, the
54-
// partial-markdown parser sometimes can't recover a triple-backtick fence
55-
// that gets split mid-token, and the final state ends up as inline <code>
56-
// instead of <pre><code>. Streaming-progressive behavior is covered by the
57-
// Phase 1 unit-variance tables; the e2e harness is for final-state
58-
// invariants and cross-stack integration.
50+
// Use a large default chunkSize so ordinary fixture responses arrive in 1-2
51+
// SSE deltas. Most e2e assertions measure the final rendered DOM, while
52+
// targeted streaming regressions opt into smaller per-fixture chunks.
5953
const mock = new LLMock({ port: 0, chunkSize: 4096 });
6054
if (entries.length > 0) {
6155
mock.addFixturesFromJSON(entries as never);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": { "userMessage": "stream a markdown comparison table regression" },
5+
"response": {
6+
"content": "Here is the comparison:\n\n| Name | Mental model | When to use |\n| --- | --- | --- |\n| Angular Signals | Synchronous value graph | Local component state |\n| RxJS | Event stream | Async flows and cancellation |\n| zone.js | Async task patching | Zone-based change detection |\n\nDone."
7+
},
8+
"chunkSize": 4,
9+
"latency": 75
10+
},
11+
{
12+
"match": { "userMessage": "stream a blockquote then a markdown table regression" },
13+
"response": {
14+
"content": "> First line of the quote.\n> Second line of the quote.\n\n| Issue | Expected behavior | Verification |\n| --- | --- | --- |\n| Button click does not update UI | UI reflects new state immediately | Click button and observe state |\n| Slow initial render | Main content appears within target | Measure first meaningful paint |"
15+
},
16+
"chunkSize": 6,
17+
"latency": 25
18+
},
19+
{
20+
"match": { "userMessage": "stream a TypeScript code fence regression" },
21+
"response": {
22+
"content": "Here is the snippet:\n\n```typescript\nconst answer = 42;\n```\n\nThe constant is available for later use."
23+
},
24+
"chunkSize": 3,
25+
"latency": 35
26+
}
27+
]
28+
}

examples/chat/angular/e2e/markdown-surfaces.spec.ts

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
// SPDX-License-Identifier: MIT
2-
import { test, expect, type Locator } from '@playwright/test';
3-
import { sendPromptAndWait } from './test-helpers';
2+
import { test, expect, type Locator, type Page } from '@playwright/test';
3+
import {
4+
attachBrowserHygiene,
5+
messageInput,
6+
openDemo,
7+
sendButton,
8+
sendPromptAndWait,
9+
waitForFinalAssistant,
10+
} from './test-helpers';
411

512
test('heading: assistant bubble renders an <h1>', async ({ page }) => {
613
const bubble = await sendPromptAndWait(page, 'respond with a heading');
@@ -55,6 +62,70 @@ test('markdown checklist matrix: rich markdown renders with escaped html', async
5562
await expect(bubble).toContainText("<script>alert('xss')</script>");
5663
});
5764

65+
test('streaming markdown table: keeps in-progress rows inside one table', async ({ page }) => {
66+
const hygiene = attachBrowserHygiene(page);
67+
await sendPrompt(page, 'stream a markdown comparison table regression');
68+
69+
const streamingAssistant = latestAssistant(page);
70+
await expect(streamingAssistant).toBeAttached({ timeout: 15_000 });
71+
72+
const samples = await collectStreamingSamples(page, streamingAssistant, 2_000);
73+
expect(contentChangedAcross(samples)).toBe(true);
74+
const tableSamples = samples.filter((sample) => sample.tableCount > 0);
75+
expect(tableSamples.length).toBeGreaterThan(2);
76+
expect(tableSamples.every((sample) => sample.tableCount <= 1)).toBe(true);
77+
expect(tableSamples.every((sample) => sample.rowsOutsideTable === 0)).toBe(true);
78+
expect(tableSamples.every((sample) => sample.detachedTableCellText.length === 0)).toBe(true);
79+
80+
const bubble = await waitForFinalAssistant(page);
81+
await expect(bubble.locator('table')).toHaveCount(1);
82+
await expect(bubble.locator('thead th')).toHaveText(['Name', 'Mental model', 'When to use']);
83+
await expect(bubble.locator('tbody tr')).toHaveCount(3);
84+
await expect(bubble.locator('tbody tr').nth(2)).toContainText('zone.js');
85+
await expect.poll(async () => tableColumnsAlign(bubble)).toBe(true);
86+
expect(hygiene.consoleErrors).toEqual([]);
87+
expect(hygiene.failedRequests).toEqual([]);
88+
});
89+
90+
test('streaming markdown table: blockquote followed by table does not throw', async ({ page }) => {
91+
const hygiene = attachBrowserHygiene(page);
92+
await sendPrompt(page, 'stream a blockquote then a markdown table regression');
93+
94+
const bubble = await waitForFinalAssistant(page);
95+
await expect(bubble.locator('blockquote')).toBeVisible();
96+
await expect(bubble.locator('blockquote')).toContainText('First line of the quote.');
97+
await expect(bubble.locator('table')).toHaveCount(1);
98+
await expect(bubble.locator('thead th')).toHaveText([
99+
'Issue',
100+
'Expected behavior',
101+
'Verification',
102+
]);
103+
await expect(bubble.locator('tbody tr')).toHaveCount(2);
104+
expect(hygiene.consoleErrors).toEqual([]);
105+
expect(hygiene.failedRequests).toEqual([]);
106+
});
107+
108+
test('streaming code fence: suppresses closing fence marker while streaming', async ({ page }) => {
109+
const hygiene = attachBrowserHygiene(page);
110+
await sendPrompt(page, 'stream a TypeScript code fence regression');
111+
112+
const streamingAssistant = latestAssistant(page);
113+
await expect(streamingAssistant).toBeAttached({ timeout: 15_000 });
114+
115+
const samples = await collectStreamingSamples(page, streamingAssistant, 4_000);
116+
expect(contentChangedAcross(samples)).toBe(true);
117+
const codeSamples = samples.filter((sample) => sample.codeBlockCount > 0);
118+
expect(codeSamples.length).toBeGreaterThan(2);
119+
expect(codeSamples.every((sample) => !sample.hasRawFenceMarker)).toBe(true);
120+
121+
const bubble = await waitForFinalAssistant(page);
122+
await expect(bubble.locator('pre code')).toHaveCount(1);
123+
await expect(bubble.locator('pre code')).toContainText('const answer = 42');
124+
expect(await bubbleContainsRawFenceMarker(bubble)).toBe(false);
125+
expect(hygiene.consoleErrors).toEqual([]);
126+
expect(hygiene.failedRequests).toEqual([]);
127+
});
128+
58129
async function tableColumnsAlign(bubble: Locator): Promise<boolean> {
59130
const table = bubble.locator('table').first();
60131
return table.evaluate((el) => {
@@ -72,3 +143,78 @@ async function tableColumnsAlign(bubble: Locator): Promise<boolean> {
72143
});
73144
});
74145
}
146+
147+
async function sendPrompt(page: Page, prompt: string): Promise<void> {
148+
await openDemo(page, '/embed');
149+
await messageInput(page).fill(prompt);
150+
await sendButton(page).click();
151+
}
152+
153+
function latestAssistant(page: Page): Locator {
154+
return page.locator('chat-message[data-role="assistant"]').last();
155+
}
156+
157+
interface StreamingMarkdownSample {
158+
readonly contentTextLength: number;
159+
readonly tableCount: number;
160+
readonly rowsOutsideTable: number;
161+
readonly detachedTableCellText: string[];
162+
readonly codeBlockCount: number;
163+
readonly hasRawFenceMarker: boolean;
164+
}
165+
166+
async function collectStreamingSamples(
167+
page: Page,
168+
bubble: Locator,
169+
minimumDurationMs: number,
170+
): Promise<StreamingMarkdownSample[]> {
171+
const samples: StreamingMarkdownSample[] = [];
172+
const startedAt = Date.now();
173+
174+
do {
175+
samples.push(await sampleStreamingMarkdown(bubble));
176+
await page.waitForTimeout(75);
177+
} while (Date.now() - startedAt < minimumDurationMs);
178+
179+
return samples;
180+
}
181+
182+
async function sampleStreamingMarkdown(bubble: Locator): Promise<StreamingMarkdownSample> {
183+
return bubble.evaluate((el) => {
184+
const looksLikeTableRowFragment = (text: string): boolean => (
185+
/\|/.test(text) || /Angular Signals|RxJS|zone\.js/.test(text)
186+
);
187+
const tables = Array.from(el.querySelectorAll('table'));
188+
const rowsOutsideTable = Array.from(el.querySelectorAll('tr')).filter(
189+
(row) => !row.closest('table'),
190+
).length;
191+
const detachedTableCellText: string[] = [];
192+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
193+
let node = walker.nextNode();
194+
while (node) {
195+
const parent = node.parentElement;
196+
const text = node.textContent?.trim() ?? '';
197+
if (text && !parent?.closest('table') && looksLikeTableRowFragment(text)) {
198+
detachedTableCellText.push(text);
199+
}
200+
node = walker.nextNode();
201+
}
202+
203+
return {
204+
contentTextLength: el.textContent?.length ?? 0,
205+
tableCount: tables.length,
206+
rowsOutsideTable,
207+
detachedTableCellText,
208+
codeBlockCount: el.querySelectorAll('pre code').length,
209+
hasRawFenceMarker: el.textContent?.includes('```') ?? false,
210+
};
211+
});
212+
}
213+
214+
async function bubbleContainsRawFenceMarker(bubble: Locator): Promise<boolean> {
215+
return bubble.evaluate((el) => el.textContent?.includes('```') ?? false);
216+
}
217+
218+
function contentChangedAcross(samples: StreamingMarkdownSample[]): boolean {
219+
return new Set(samples.map((sample) => sample.contentTextLength)).size > 2;
220+
}

examples/chat/angular/e2e/test-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export function attachBrowserHygiene(page: Page): {
1212
if (msg.type() !== 'error') return;
1313
const text = msg.text();
1414
if (/PostHog|ERR_NAME_NOT_RESOLVED|license/i.test(text)) return;
15+
if (/409 \(Conflict\)/i.test(text)) return;
1516
consoleErrors.push(text);
1617
});
1718
page.on('pageerror', (err) => {

examples/chat/angular/src/app/shell/demo-shell.component.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ export function isConflict(err: unknown): boolean {
164164
// subagent dispatches and to materialize agent.subagents() from the
165165
// resulting tools:<id>-namespaced stream events.
166166
subagentToolNames: ['research'],
167+
// The canonical graph has side-effect LLM nodes such as generate_title;
168+
// only the generate node's token stream belongs in the chat transcript.
169+
transcriptNodeNames: ['generate'],
167170
telemetry: (event) => telemetrySink?.(event),
168171
}),
169172
{ provide: DEMO_AGENT, useFactory: () => inject(DemoShell).agent },

libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,30 @@ describe('ChatStreamingMdComponent — streaming table rendering', () => {
111111
}
112112
});
113113

114+
it('streams a realistic comparison table as one table across small chunks', () => {
115+
host.streaming.set(true);
116+
const content =
117+
'Here is the comparison:\n\n' +
118+
'| Name | Mental model | When to use |\n' +
119+
'| --- | --- | --- |\n' +
120+
'| Angular Signals | Synchronous value graph | Local component state |\n' +
121+
'| RxJS | Event stream | Async flows and cancellation |\n' +
122+
'| zone.js | Async task patching | Zone-based change detection |\n\n' +
123+
'Done.';
124+
125+
for (let i = 6; i <= content.length; i += 6) {
126+
grow(content.slice(0, i));
127+
const tableCount = el.querySelectorAll('table').length;
128+
if (tableCount > 0) {
129+
expect(tableCount, `one table at ${JSON.stringify(content.slice(0, i).slice(-40))}`).toBe(1);
130+
}
131+
expect(
132+
[...el.querySelectorAll('p')].some((p) => (p.textContent || '').includes('| zone.js')),
133+
`no detached row paragraph at ${JSON.stringify(content.slice(0, i).slice(-40))}`,
134+
).toBe(false);
135+
}
136+
});
137+
114138
it('keeps a finalized partial body row in the table when the stream pauses', () => {
115139
vi.useFakeTimers();
116140
try {

libs/langgraph/src/lib/agent.provider.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export interface AgentConfig<
4747
filterSubagentMessages?: boolean;
4848
/** Tool names that indicate a subagent invocation. */
4949
subagentToolNames?: string[];
50+
/**
51+
* LangGraph node names whose `messages-tuple` LLM chunks should be projected
52+
* into the main chat transcript. Omit to accept all top-level message chunks.
53+
*/
54+
transcriptNodeNames?: string[];
5055
}
5156

5257
/**
@@ -84,6 +89,7 @@ function agentFactory<T>(): LangGraphAgent<T> {
8489
...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}),
8590
...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}),
8691
...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}),
92+
...(config.transcriptNodeNames !== undefined ? { transcriptNodeNames: config.transcriptNodeNames } : {}),
8793
});
8894
}
8995

libs/langgraph/src/lib/agent.types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,14 @@ export interface AgentOptions<T, _ResolvedBag extends BagTemplate> {
283283
filterSubagentMessages?: boolean;
284284
/** Tool names that indicate a subagent invocation. */
285285
subagentToolNames?: string[];
286+
/**
287+
* LangGraph node names whose `messages-tuple` LLM chunks should be projected
288+
* into the main chat transcript. Omit to accept all top-level message chunks.
289+
*
290+
* Use this when a graph has side-effect LLM nodes, such as title generation,
291+
* whose streamed model output should not render as assistant chat content.
292+
*/
293+
transcriptNodeNames?: string[];
286294
}
287295

288296
// ── SubagentStreamRef ────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)