Skip to content

Commit b6add32

Browse files
committed
fix(chat): reclassify replaced content during stage playback
1 parent 33e490a commit b6add32

3 files changed

Lines changed: 48 additions & 7 deletions

File tree

apps/website/e2e/home-stage.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ const progress = (page: Page) =>
4343
);
4444

4545
test.describe('homepage stage', () => {
46+
test('gradual playback replaces the render introduction with an editable surface', async ({ page }) => {
47+
test.skip(process.env['STAGE_LIVE_FRAME'] !== 'true', 'requires the matching stage replay deployment');
48+
await page.setViewportSize({ width: 1440, height: 900 });
49+
await page.goto('/');
50+
const act = page.locator('[data-stage-act]');
51+
await expect(act).toHaveAttribute('data-state', 'ready');
52+
const frame = page.frameLocator('.stage-frame-iframe');
53+
for (const p of [0.87, 0.89, 0.91, 0.93, 0.95, 1]) {
54+
await scrollAct(page, p);
55+
await expect(act).toHaveAttribute('data-interactive', '', { timeout: 30_000 });
56+
}
57+
const notes = frame.getByRole('textbox', { name: 'Follow-up notes' });
58+
await notes.fill('Verify the next retention review.');
59+
await expect(notes).toHaveValue('Verify the next retention review.');
60+
await expect(frame.locator('a2ui-surface')).toHaveCount(1);
61+
});
62+
4663
test('settled replay allows State inspection and form edits; page motion resumes playback', async ({ page }) => {
4764
test.skip(process.env['STAGE_LIVE_FRAME'] !== 'true', 'requires the matching stage replay deployment');
4865
await page.setViewportSize({ width: 1440, height: 900 });

libs/chat/src/lib/streaming/content-classifier.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,31 @@ describe('ContentClassifier', () => {
125125
});
126126

127127
describe('type transitions', () => {
128+
it('reclassifies a longer A2UI replacement after a streamed introduction', () => {
129+
const c = setup();
130+
c.update("I'll render a compact cleanup report UI now.");
131+
expect(c.type()).toBe('markdown');
132+
133+
c.update('---a2ui_JSON---\n' + JSON.stringify({
134+
version: 'v0.9',
135+
createSurface: { surfaceId: 'cleanup', catalogId: 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json' },
136+
}) + '\n' + JSON.stringify({
137+
version: 'v0.9',
138+
updateComponents: { surfaceId: 'cleanup', components: [{ id: 'root', component: 'Text', text: 'Cleanup report' }] },
139+
}) + '\n');
140+
141+
expect(c.type()).toBe('a2ui');
142+
expect(c.a2uiSurfaces().has('cleanup')).toBe(true);
143+
expect(c.markdown()).toBe('');
144+
});
145+
146+
it('reparses an equal-length JSON replacement instead of keeping stale elements', () => {
147+
const c = setup();
148+
c.update('{"root":"first","elements":{}}');
149+
c.update('{"root":"other","elements":{}}');
150+
expect(c.spec()?.root).toBe('other');
151+
});
152+
128153
it('never downgrades from markdown', () => {
129154
const c = setup();
130155
c.update('Hello');

libs/chat/src/lib/streaming/content-classifier.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function createContentClassifier(): ContentClassifier {
4646
const errorsSignal = signal<string[]>([]);
4747

4848
let processedLength = 0;
49+
let previousContent = '';
4950
let store: ParseTreeStore | null = null;
5051
let jsonStartIndex = 0;
5152

@@ -148,15 +149,13 @@ export function createContentClassifier(): ContentClassifier {
148149
// NG0600 forbids writing signals during change detection; untracked()
149150
// opts out of the reactive graph for this imperative push-based update.
150151
untracked(() => {
151-
// If content shrunk vs. last seen length, the underlying message was
152-
// replaced (e.g. via langgraph RemoveMessage / id-match content
153-
// replacement followed by force-refresh-from-server). Reset state so
154-
// the new content is classified fresh — otherwise the classifier
155-
// keeps the streamed (pre-mutation) markdown/json type and the UI
156-
// never updates.
157-
if (content.length < processedLength) {
152+
// A same-id replacement can be longer than its streamed introduction
153+
// (e.g. the render tool replaces prose with an A2UI payload). Only
154+
// append-only content can reuse the existing classification and parser.
155+
if (!content.startsWith(previousContent)) {
158156
resetState();
159157
}
158+
previousContent = content;
160159
const currentType = typeSignal();
161160

162161
if (currentType === 'pending') {

0 commit comments

Comments
 (0)