Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions libs/chat/src/lib/streaming/content-classifier.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
37 changes: 28 additions & 9 deletions libs/chat/src/lib/streaming/content-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ContentClassifier {
readonly elementStates: Signal<Map<string, ElementAccumulationState>>;
readonly a2uiSurfaces: Signal<Map<string, A2uiSurface>>;
readonly streaming: Signal<boolean>;
readonly errors: Signal<string[]>;
dispose(): void;
}

Expand All @@ -28,6 +29,7 @@ export function createContentClassifier(): ContentClassifier {
const specSignal = signal<Spec | null>(null);
const elementStatesSignal = signal<Map<string, ElementAccumulationState>>(new Map());
const streamingSignal = signal<boolean>(false);
const errorsSignal = signal<string[]>([]);

let processedLength = 0;
let store: ParseTreeStore | null = null;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand All @@ -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)]);
}
}
}
}
Expand All @@ -172,6 +190,7 @@ export function createContentClassifier(): ContentClassifier {
elementStates: elementStatesSignal.asReadonly(),
a2uiSurfaces: a2uiSurfacesSignal.asReadonly(),
streaming: streamingSignal.asReadonly(),
errors: errorsSignal.asReadonly(),
dispose,
};
}
69 changes: 69 additions & 0 deletions libs/partial-json/src/lib/parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading