From 86f84c7e39de214b4d27f57c5601decbb1fcd503 Mon Sep 17 00:00:00 2001 From: Stephen Allen Date: Fri, 26 Jun 2026 10:59:37 -0500 Subject: [PATCH] fix(live): skip empty metadata events to prevent blank chat rows --- .../components/chat/chat.component.spec.ts | 158 ++++++++++++++++++ src/app/components/chat/chat.component.ts | 53 ++++++ src/app/core/models/types.ts | 5 + 3 files changed, 216 insertions(+) diff --git a/src/app/components/chat/chat.component.spec.ts b/src/app/components/chat/chat.component.spec.ts index 97ed3138..05b39120 100644 --- a/src/app/components/chat/chat.component.spec.ts +++ b/src/app/components/chat/chat.component.spec.ts @@ -1678,4 +1678,162 @@ describe('ChatComponent', () => { expect(mockSnackBar.open).toHaveBeenCalledWith('Failed to refresh sessions.', 'OK'); })); }); + + describe('empty metadata events', () => { + // Actions as serialized by the ADK server on live events: keys present but + // every sub-object empty. + const EMPTY_ACTIONS = { + stateDelta: {}, + artifactDelta: {}, + requestedAuthConfigs: {}, + requestedToolConfirmations: {}, + }; + + beforeEach(() => { + component.uiEvents.set([]); + component.eventData = new Map(); + }); + + describe('isEmptyMetadataEvent', () => { + it('returns true for a session resumption update with empty actions', () => { + const event = { + id: 'resumption-1', + author: 'agent', + liveSessionResumptionUpdate: {newHandle: 'abc', resumable: true}, + actions: {...EMPTY_ACTIONS}, + }; + expect((component as any).isEmptyMetadataEvent(event)).toBeTrue(); + }); + + it('returns true for a usage-metadata-only event with empty actions', () => { + const event = { + id: 'usage-1', + author: 'agent', + usageMetadata: {totalTokenCount: 858}, + actions: {...EMPTY_ACTIONS}, + }; + expect((component as any).isEmptyMetadataEvent(event)).toBeTrue(); + }); + + it('returns false when no metadata marker is present', () => { + const event = { + id: 'plain-1', + author: 'bot', + content: {parts: [{text: 'hello'}]}, + actions: {...EMPTY_ACTIONS}, + }; + expect((component as any).isEmptyMetadataEvent(event)).toBeFalse(); + }); + + it('returns false when a marker event also carries content', () => { + const event = { + id: 'final-1', + author: 'bot', + usageMetadata: {totalTokenCount: 858}, + content: {parts: [{text: 'final answer'}]}, + actions: {...EMPTY_ACTIONS}, + }; + expect((component as any).isEmptyMetadataEvent(event)).toBeFalse(); + }); + + it('returns false when a marker event carries a real stateDelta', () => { + const event = { + id: 'state-1', + author: 'agent', + usageMetadata: {totalTokenCount: 1}, + actions: {...EMPTY_ACTIONS, stateDelta: {key: 'value'}}, + }; + expect((component as any).isEmptyMetadataEvent(event)).toBeFalse(); + }); + + it('returns false when a marker event signals turnComplete', () => { + const event = { + id: 'turn-1', + author: 'agent', + usageMetadata: {totalTokenCount: 1}, + turnComplete: true, + actions: {...EMPTY_ACTIONS}, + }; + expect((component as any).isEmptyMetadataEvent(event)).toBeFalse(); + }); + }); + + describe('actionsAreEmpty', () => { + it('treats null/undefined actions as empty', () => { + expect((component as any).actionsAreEmpty(undefined)).toBeTrue(); + expect((component as any).actionsAreEmpty(null)).toBeTrue(); + }); + + it('treats an object of empty sub-objects as empty', () => { + expect((component as any).actionsAreEmpty({...EMPTY_ACTIONS})).toBeTrue(); + }); + + it('treats a populated stateDelta as non-empty', () => { + expect((component as any).actionsAreEmpty({stateDelta: {k: 1}})).toBeFalse(); + }); + + it('treats transferToAgent as non-empty', () => { + expect((component as any).actionsAreEmpty({transferToAgent: 'other_agent'})) + .toBeFalse(); + }); + + it('treats endOfAgent === true as non-empty', () => { + expect((component as any).actionsAreEmpty({endOfAgent: true})).toBeFalse(); + expect((component as any).actionsAreEmpty({endOfAgent: false})).toBeTrue(); + }); + }); + + describe('appendEventRow integration', () => { + it('does not render a session resumption update', () => { + (component as any).appendEventRow({ + id: 'resumption-1', + author: 'agent', + liveSessionResumptionUpdate: {newHandle: 'abc', resumable: true}, + actions: {...EMPTY_ACTIONS}, + }); + expect(component.uiEvents().length).toBe(0); + }); + + it('does not render a usage-metadata-only event', () => { + (component as any).appendEventRow({ + id: 'usage-1', + author: 'agent', + usageMetadata: {totalTokenCount: 858}, + actions: {...EMPTY_ACTIONS}, + }); + expect(component.uiEvents().length).toBe(0); + }); + + it('renders a normal text event (non-live path unaffected)', () => { + (component as any).appendEventRow({ + id: 'text-1', + author: 'bot', + content: {parts: [{text: 'hello world'}]}, + }); + expect(component.uiEvents().length).toBe(1); + expect(component.uiEvents()[0].text).toBe('hello world'); + }); + + it('renders an event whose only signal is a real stateDelta', () => { + (component as any).appendEventRow({ + id: 'state-1', + author: 'agent', + actions: {...EMPTY_ACTIONS, stateDelta: {key: 'value'}}, + }); + expect(component.uiEvents().length).toBe(1); + }); + + it('renders a marker event that also carries content', () => { + (component as any).appendEventRow({ + id: 'final-1', + author: 'bot', + usageMetadata: {totalTokenCount: 858}, + content: {parts: [{text: 'final answer'}]}, + actions: {...EMPTY_ACTIONS}, + }); + expect(component.uiEvents().length).toBe(1); + expect(component.uiEvents()[0].text).toBe('final answer'); + }); + }); + }); }); diff --git a/src/app/components/chat/chat.component.ts b/src/app/components/chat/chat.component.ts index 3a1eb0b3..5ed11323 100644 --- a/src/app/components/chat/chat.component.ts +++ b/src/app/components/chat/chat.component.ts @@ -1210,7 +1210,60 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } } + /** + * Whether an event carries only bookkeeping metadata and has nothing to + * render. Live sessions emit such events continuously: session resumption + * handle updates, and usage-metadata-only events sent just before + * turnComplete. Only skips events that carry one of those metadata markers + * AND have nothing else renderable. + */ + private isEmptyMetadataEvent(apiEvent: any): boolean { + if (!apiEvent) { + return false; + } + + const hasMetadataMarker = + apiEvent.liveSessionResumptionUpdate !== undefined || + apiEvent.usageMetadata !== undefined; + if (!hasMetadataMarker) { + return false; + } + + return apiEvent.content === undefined + && apiEvent.output === undefined + && apiEvent.inputTranscription === undefined + && apiEvent.outputTranscription === undefined + && !apiEvent.errorMessage + && !apiEvent.errorCode + && !apiEvent.systemInstructionChanged + && !apiEvent.turnComplete + && !apiEvent.interrupted + && !(apiEvent.longRunningToolIds?.length) + && this.actionsAreEmpty(apiEvent.actions); + } + + /** + * Whether an event's actions carry no real content. Events often arrive with + * empty action sub-objects (e.g. { stateDelta: {}, artifactDelta: {}, + * requestedAuthConfigs: {} }), so a key-count check is insufficient. + */ + private actionsAreEmpty(actions: any): boolean { + if (!actions) { + return true; + } + return Object.values(actions).every((v) => + v == null || + v === false || + (Array.isArray(v) && v.length === 0) || + (typeof v === 'object' && Object.keys(v).length === 0) + ); + } + private appendEventRow(apiEvent: any, reverseOrder: boolean = false) { + if (this.isEmptyMetadataEvent(apiEvent)) { + return; + } + if (apiEvent.inputTranscription !== undefined) { apiEvent.author = 'user'; } else if (apiEvent.outputTranscription !== undefined) { diff --git a/src/app/core/models/types.ts b/src/app/core/models/types.ts index 845fd9ae..e5552fc2 100644 --- a/src/app/core/models/types.ts +++ b/src/app/core/models/types.ts @@ -136,6 +136,11 @@ export declare interface Event extends LlmResponse { systemInstructionChanged?: boolean; precedingSystemInstruction?: string; currentSystemInstruction?: string; + liveSessionResumptionUpdate?: { + newHandle?: string; + resumable?: boolean; + lastConsumedClientMessageIndex?: number; + }; } export interface ComputerUsePayload {