Skip to content
Open
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
158 changes: 158 additions & 0 deletions src/app/components/chat/chat.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
});
53 changes: 53 additions & 0 deletions src/app/components/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/app/core/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading