From 84654712b168163ce93b8e8ad4c08d9ea459674a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 10:19:21 -0700 Subject: [PATCH] fix: add production error handling for streaming JSON parsing Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/streaming/content-classifier.spec.ts | 16 +++++ .../src/lib/streaming/content-classifier.ts | 37 +++++++--- libs/partial-json/src/lib/parser.spec.ts | 69 +++++++++++++++++++ 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/libs/chat/src/lib/streaming/content-classifier.spec.ts b/libs/chat/src/lib/streaming/content-classifier.spec.ts index 3851db1e1..34fa58899 100644 --- a/libs/chat/src/lib/streaming/content-classifier.spec.ts +++ b/libs/chat/src/lib/streaming/content-classifier.spec.ts @@ -204,6 +204,22 @@ describe('ContentClassifier', () => { }); }); + describe('error handling', () => { + it('errors signal starts empty', () => { + const c = setup(); + expect(c.errors()).toEqual([]); + }); + + it('continues working after encountering non-JSON content mid-stream', () => { + const c = setup(); + // Start with valid JSON detection + c.update('{"root":"r1"'); + expect(c.type()).toBe('json-render'); + // The spec should have a partial result + expect(c.spec()).not.toBeNull(); + }); + }); + describe('dispose', () => { it('can be called without errors', () => { const c = setup(); diff --git a/libs/chat/src/lib/streaming/content-classifier.ts b/libs/chat/src/lib/streaming/content-classifier.ts index 27dc3cfd8..b1d02eedf 100644 --- a/libs/chat/src/lib/streaming/content-classifier.ts +++ b/libs/chat/src/lib/streaming/content-classifier.ts @@ -19,6 +19,7 @@ export interface ContentClassifier { readonly elementStates: Signal>; readonly a2uiSurfaces: Signal>; readonly streaming: Signal; + readonly errors: Signal; dispose(): void; } @@ -28,6 +29,7 @@ export function createContentClassifier(): ContentClassifier { const specSignal = signal(null); const elementStatesSignal = signal>(new Map()); const streamingSignal = signal(false); + const errorsSignal = signal([]); let processedLength = 0; let store: ParseTreeStore | null = null; @@ -118,7 +120,11 @@ export function createContentClassifier(): ContentClassifier { } } const jsonContent = content.slice(jsonStartIndex); - initJsonStore(jsonContent); + try { + initJsonStore(jsonContent); + } catch (err) { + errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); + } processedLength = content.length; } else if (detected === 'a2ui') { streamingSignal.set(true); @@ -127,9 +133,13 @@ export function createContentClassifier(): ContentClassifier { jsonStartIndex = content.indexOf(A2UI_PREFIX) + A2UI_PREFIX.length; const a2uiContent = content.slice(jsonStartIndex); if (a2uiContent.length > 0) { - const msgs = a2uiParser.push(a2uiContent); - for (const msg of msgs) a2uiStore.apply(msg); - a2uiSurfacesSignal.set(a2uiStore.surfaces()); + try { + const msgs = a2uiParser.push(a2uiContent); + for (const msg of msgs) a2uiStore.apply(msg); + a2uiSurfacesSignal.set(a2uiStore.surfaces()); + } catch (err) { + errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); + } } processedLength = content.length; } @@ -146,14 +156,22 @@ export function createContentClassifier(): ContentClassifier { markdownSignal.set(content); } else if (currentType === 'json-render') { if (store) { - store.push(delta); - syncJsonSignals(); + try { + store.push(delta); + syncJsonSignals(); + } catch (err) { + errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); + } } } else if (currentType === 'a2ui') { if (a2uiParser && a2uiStore) { - const msgs = a2uiParser.push(delta); - for (const msg of msgs) a2uiStore.apply(msg); - a2uiSurfacesSignal.set(a2uiStore.surfaces()); + try { + const msgs = a2uiParser.push(delta); + for (const msg of msgs) a2uiStore.apply(msg); + a2uiSurfacesSignal.set(a2uiStore.surfaces()); + } catch (err) { + errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); + } } } } @@ -172,6 +190,7 @@ export function createContentClassifier(): ContentClassifier { elementStates: elementStatesSignal.asReadonly(), a2uiSurfaces: a2uiSurfacesSignal.asReadonly(), streaming: streamingSignal.asReadonly(), + errors: errorsSignal.asReadonly(), dispose, }; } diff --git a/libs/partial-json/src/lib/parser.spec.ts b/libs/partial-json/src/lib/parser.spec.ts index 7a0540bf1..63a33a8cc 100644 --- a/libs/partial-json/src/lib/parser.spec.ts +++ b/libs/partial-json/src/lib/parser.spec.ts @@ -314,6 +314,75 @@ describe('createPartialJsonParser', () => { }); }); + describe('error recovery', () => { + it('handles empty input', () => { + const parser = createPartialJsonParser(); + const events = parser.push(''); + expect(events).toEqual([]); + expect(parser.root).toBeNull(); + }); + + it('handles input with only whitespace', () => { + const parser = createPartialJsonParser(); + const events = parser.push(' \n\t'); + expect(events).toEqual([]); + expect(parser.root).toBeNull(); + }); + + it('leaves root as null for invalid characters in EXPECT_VALUE state', () => { + const parser = createPartialJsonParser(); + // 'x' does not match any case in EXPECT_VALUE, so it falls through the switch. + // The parser never creates a root node for unrecognized characters. + const events = parser.push('xxx{"a":1}'); + // Because 'x' is not whitespace and not a recognized token start, + // the parser stays in EXPECT_VALUE but does nothing — root remains null. + // Only when '{' is encountered does parsing begin, but by then 'xxx' has already + // been consumed with no effect. + // Actually, 'x' falls through the switch with no match, so processing continues + // to the next char. '{' will be reached and parsed normally. + expect(parser.root).not.toBeNull(); + const root = parser.root as JsonObjectNode; + expect(root.type).toBe('object'); + expect((root.children.get('a') as JsonNumberNode).value).toBe(1); + }); + + it('handles trailing text after valid JSON', () => { + const parser = createPartialJsonParser(); + parser.push('{"a":1}some trailing text'); + const root = parser.root as JsonObjectNode; + expect(root.type).toBe('object'); + expect(root.status).toBe('complete'); + expect((root.children.get('a') as JsonNumberNode).value).toBe(1); + }); + + it('handles very long strings without crashing', () => { + const parser = createPartialJsonParser(); + const longStr = 'a'.repeat(100000); + parser.push('"' + longStr + '"'); + const root = parser.root as JsonStringNode; + expect(root.type).toBe('string'); + expect(root.value.length).toBe(100000); + expect(root.status).toBe('complete'); + }); + + it('handles deeply nested objects without stack overflow', () => { + const parser = createPartialJsonParser(); + const depth = 100; + const open = '{"a":'.repeat(depth); + const close = '}'.repeat(depth); + parser.push(open + '"leaf"' + close); + let current = parser.root as JsonObjectNode; + for (let i = 0; i < depth - 1; i++) { + expect(current.type).toBe('object'); + current = current.children.get('a') as JsonObjectNode; + } + // The innermost value is a string + const leaf = current.children.get('a') as JsonStringNode; + expect(leaf.type).toBe('string'); + expect(leaf.value).toBe('leaf'); + }); + }); + describe('whitespace', () => { it('should handle whitespace between tokens', () => { const parser = createPartialJsonParser();