Skip to content

Commit 41c0ae8

Browse files
google-genai-botcopybara-github
authored andcommitted
- Refining the NewMessage type in AgentRunRequest for better type safety.
- Triggering change detection in the session tab after data retrieval to prevent stale UI. - Bugfix and unit tests for the resizable drawer and various services. - Enhancing error handling in the trace tab component. PiperOrigin-RevId: 813608332
1 parent b9567a3 commit 41c0ae8

File tree

7 files changed

+315
-19
lines changed

7 files changed

+315
-19
lines changed

src/app/components/session-tab/session-tab.component.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515
* limitations under the License.
1616
*/
1717

18-
import {Component, EventEmitter, Input, OnInit, Output, Inject} from '@angular/core';
18+
import {NgClass} from '@angular/common';
19+
import {ChangeDetectorRef, Component, EventEmitter, Inject, inject, Input, OnInit, Output} from '@angular/core';
1920
import {MatDialog} from '@angular/material/dialog';
2021
import {Subject} from 'rxjs';
2122
import {switchMap} from 'rxjs/operators';
23+
2224
import {Session} from '../../core/models/Session';
23-
import {SessionService, SESSION_SERVICE} from '../../core/services/session.service';
24-
import { NgClass } from '@angular/common';
25+
import {SESSION_SERVICE, SessionService} from '../../core/services/session.service';
2526

2627
@Component({
2728
selector: 'app-session-tab',
@@ -40,6 +41,7 @@ export class SessionTabComponent implements OnInit {
4041
sessionList: any[] = [];
4142

4243
private refreshSessionsSubject = new Subject<void>();
44+
private readonly changeDetectorRef = inject(ChangeDetectorRef);
4345

4446
constructor(
4547
@Inject(SESSION_SERVICE) private sessionService: SessionService,
@@ -58,6 +60,7 @@ export class SessionTabComponent implements OnInit {
5860
Number(b.lastUpdateTime) - Number(a.lastUpdateTime),
5961
);
6062
this.sessionList = res;
63+
this.changeDetectorRef.detectChanges();
6164
});
6265
}
6366

src/app/components/trace-tab/trace-tab.component.spec.ts

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,136 @@
1414
* See the License for the specific language governing permissions and
1515
* limitations under the License.
1616
*/
17+
import {HarnessLoader} from '@angular/cdk/testing';
18+
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
1719
import {ComponentFixture, TestBed} from '@angular/core/testing';
20+
import {MatExpansionPanelHarness} from '@angular/material/expansion/testing';
21+
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
1822

23+
import {Span} from '../../core/models/Trace';
24+
import {TRACE_SERVICE} from '../../core/services/trace.service';
1925

26+
import {MockTraceService} from './../../core/services/testing/mock-trace.service';
2027
import {TraceTabComponent} from './trace-tab.component';
2128

29+
const MOCK_TRACE_DATA: Span[] = [
30+
{
31+
name: 'agent.act',
32+
start_time: 1733084700000000000,
33+
end_time: 1733084760000000000,
34+
span_id: 'span-1',
35+
trace_id: 'trace-1',
36+
attributes: {
37+
'event_id': 1,
38+
'gcp.vertex.agent.invocation_id': '21332-322222',
39+
'gcp.vertex.agent.llm_request':
40+
'{"contents":[{"role":"user","parts":[{"text":"Hello"}]},{"role":"agent","parts":[{"text":"Hi. What can I help you with?"}]},{"role":"user","parts":[{"text":"I need help with my project."}]}]}',
41+
},
42+
},
43+
{
44+
name: 'tool.invoke',
45+
start_time: 1733084705000000000,
46+
end_time: 1733084755000000000,
47+
span_id: 'span-2',
48+
parent_span_id: 'span-1',
49+
trace_id: 'trace-1',
50+
attributes: {
51+
'tool_name': 'project_helper',
52+
},
53+
},
54+
];
55+
2256
describe('TraceTabComponent', () => {
2357
let component: TraceTabComponent;
2458
let fixture: ComponentFixture<TraceTabComponent>;
59+
let loader: HarnessLoader;
2560

2661
beforeEach(async () => {
27-
await TestBed.configureTestingModule({
28-
imports: [TraceTabComponent]
29-
})
62+
await TestBed
63+
.configureTestingModule({
64+
imports: [TraceTabComponent, NoopAnimationsModule],
65+
providers: [
66+
{provide: TRACE_SERVICE, useClass: MockTraceService},
67+
],
68+
})
3069
.compileComponents();
3170

3271
fixture = TestBed.createComponent(TraceTabComponent);
3372
component = fixture.componentInstance;
73+
loader = TestbedHarnessEnvironment.loader(fixture);
3474
fixture.detectChanges();
3575
});
3676

3777
it('should create', () => {
3878
expect(component).toBeTruthy();
3979
});
80+
81+
it('should display no invocations if traceData is empty', async () => {
82+
const expansionPanels = await loader.getAllHarnesses(
83+
MatExpansionPanelHarness,
84+
);
85+
expect(expansionPanels.length).toBe(0);
86+
});
87+
88+
describe('with trace data', () => {
89+
const MOCK_TRACE_DATA_WITH_MULTIPLE_TRACES: Span[] = [
90+
...MOCK_TRACE_DATA,
91+
{
92+
name: 'agent.act-2',
93+
start_time: 1733084700000000000,
94+
end_time: 1733084760000000000,
95+
span_id: 'span-10',
96+
trace_id: 'trace-2',
97+
attributes: {
98+
'event_id': 10,
99+
'gcp.vertex.agent.invocation_id': 'invoc-2',
100+
'gcp.vertex.agent.llm_request':
101+
'{"contents":[{"role":"user","parts":[{"text":"Another user message"}]}]}',
102+
},
103+
},
104+
];
105+
106+
beforeEach(async () => {
107+
fixture.componentRef.setInput(
108+
'traceData',
109+
MOCK_TRACE_DATA_WITH_MULTIPLE_TRACES,
110+
);
111+
fixture.detectChanges();
112+
await fixture.whenStable();
113+
});
114+
115+
it('should group traces by trace_id and display as expansion panels',
116+
async () => {
117+
const expansionPanels = await loader.getAllHarnesses(
118+
MatExpansionPanelHarness,
119+
);
120+
expect(expansionPanels.length).toBe(2);
121+
});
122+
123+
it('should display user message as panel title', async () => {
124+
const expansionPanels = await loader.getAllHarnesses(
125+
MatExpansionPanelHarness,
126+
);
127+
expect(await expansionPanels[0].getTitle())
128+
.toBe(
129+
'I need help with my project.',
130+
);
131+
expect(await expansionPanels[1].getTitle()).toBe('Another user message');
132+
});
133+
134+
it('should pass correct data to trace-tree component', async () => {
135+
spyOn(component, 'findInvocIdFromTraceId').and.callThrough();
136+
const expansionPanels = await loader.getAllHarnesses(
137+
MatExpansionPanelHarness,
138+
);
139+
await expansionPanels[0].expand();
140+
fixture.detectChanges();
141+
142+
expect(component.findInvocIdFromTraceId).toHaveBeenCalledWith('trace-1');
143+
const traceTree = fixture.nativeElement.querySelector('app-trace-tree');
144+
expect(traceTree).toBeTruthy();
145+
// Further inspection of trace-tree inputs would require a harness or
146+
// mocking TraceTreeComponent
147+
});
148+
});
40149
});

src/app/components/trace-tab/trace-tab.component.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ export class TraceTabComponent implements OnInit, OnChanges {
7070
const eventItem = group?.find(
7171
item => item.attributes !== undefined &&
7272
'gcp.vertex.agent.invocation_id' in item.attributes)
73+
74+
if (!eventItem) {
75+
return '[no invocation id found]';
76+
}
77+
7378
const requestJson =
7479
JSON.parse(eventItem.attributes['gcp.vertex.agent.llm_request']) as LlmRequest
7580
const userContent =

src/app/core/models/AgentRunRequest.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ export interface AgentRunRequest {
1919
appName: string;
2020
userId: string;
2121
sessionId: string;
22-
newMessage: any;
22+
newMessage: {
23+
parts: Array<{
24+
text?: string,
25+
'function_response'?: {id?: string; name?: string; response?: any}
26+
}>,
27+
role: string,
28+
};
2329
functionCallEventId?: string;
2430
streaming?: boolean;
2531
stateDelta?: any;

src/app/core/services/agent.service.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const RUN_SSE_PAYLOAD = {
4242
sessionId: SESSION_ID,
4343
appName: TEST_APP_NAME,
4444
userId: USER_ID,
45-
newMessage: NEW_MESSAGE,
45+
newMessage: {parts: [{text: NEW_MESSAGE}], role: 'user'},
4646
};
4747

4848
describe('AgentService', () => {
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import {Component} from '@angular/core';
19+
import {ComponentFixture, TestBed} from '@angular/core/testing';
20+
import {By} from '@angular/platform-browser';
21+
22+
import {ResizableDrawerDirective} from './resizable-drawer.directive';
23+
24+
// Directive constants
25+
const SIDE_DRAWER_WIDTH_VAR = '--side-drawer-width';
26+
const INITIAL_WIDTH = 570;
27+
const MIN_WIDTH = 310;
28+
29+
// Test constants
30+
const MOCKED_WINDOW_WIDTH = 2000;
31+
const MAX_WIDTH = MOCKED_WINDOW_WIDTH / 2; // 1000
32+
33+
@Component({
34+
template: `
35+
<div appResizableDrawer>Drawer</div>
36+
<div class="resize-handler"></div>
37+
`,
38+
standalone: true,
39+
imports: [ResizableDrawerDirective],
40+
})
41+
class TestHostComponent {
42+
}
43+
44+
describe('ResizableDrawerDirective', () => {
45+
let fixture: ComponentFixture<TestHostComponent>;
46+
let directiveElement: HTMLElement;
47+
let resizeHandle: HTMLElement;
48+
let body: HTMLElement;
49+
let innerWidthSpy: jasmine.Spy;
50+
51+
/**
52+
* Reads the drawer width from the CSS variable on the root element.
53+
*/
54+
function getDrawerWidth(): number {
55+
const widthStr = getComputedStyle(document.documentElement)
56+
.getPropertyValue(SIDE_DRAWER_WIDTH_VAR);
57+
return parseFloat(widthStr);
58+
}
59+
60+
/**
61+
* Dispatches a mouse event with a given clientX position.
62+
*/
63+
function dispatchMouseEvent(
64+
element: EventTarget, type: string, clientX: number) {
65+
element.dispatchEvent(new MouseEvent(type, {
66+
bubbles: true,
67+
clientX,
68+
}));
69+
}
70+
71+
beforeEach(() => {
72+
TestBed.configureTestingModule({
73+
imports: [TestHostComponent],
74+
});
75+
innerWidthSpy = spyOnProperty(window, 'innerWidth', 'get');
76+
innerWidthSpy.and.returnValue(MOCKED_WINDOW_WIDTH);
77+
fixture = TestBed.createComponent(TestHostComponent);
78+
directiveElement =
79+
fixture.debugElement.query(By.directive(ResizableDrawerDirective))
80+
.nativeElement;
81+
resizeHandle =
82+
fixture.debugElement.query(By.css('.resize-handler')).nativeElement;
83+
body = document.body;
84+
fixture.detectChanges(); // This calls ngAfterViewInit
85+
window.dispatchEvent(new Event('resize'));
86+
});
87+
88+
afterEach(() => {
89+
document.documentElement.style.removeProperty(SIDE_DRAWER_WIDTH_VAR);
90+
body.classList.remove('resizing');
91+
});
92+
93+
it('should set initial width to 570px after view init', () => {
94+
// Assert
95+
expect(directiveElement.style.width).toBe('var(--side-drawer-width)');
96+
expect(getDrawerWidth()).toBe(INITIAL_WIDTH);
97+
});
98+
99+
it('should resize drawer on mouse drag', () => {
100+
// Arrange
101+
const startClientX = 600;
102+
const moveClientX = 650; // Drag 50px to the right
103+
const expectedWidth = INITIAL_WIDTH + (moveClientX - startClientX);
104+
105+
// Act
106+
dispatchMouseEvent(resizeHandle, 'mousedown', startClientX);
107+
dispatchMouseEvent(document, 'mousemove', moveClientX);
108+
fixture.detectChanges();
109+
110+
// Assert
111+
expect(getDrawerWidth()).toBe(expectedWidth);
112+
expect(body.classList).toContain('resizing');
113+
114+
// Act: Release mouse
115+
dispatchMouseEvent(document, 'mouseup', moveClientX);
116+
fixture.detectChanges();
117+
118+
// Assert: Resizing class is removed
119+
expect(body.classList).not.toContain('resizing');
120+
});
121+
122+
it('should not resize below min width', () => {
123+
// Arrange
124+
const startClientX = 600;
125+
const moveClientX = 100; // Attempt to drag far left (-500px)
126+
127+
// Act
128+
dispatchMouseEvent(resizeHandle, 'mousedown', startClientX);
129+
dispatchMouseEvent(document, 'mousemove', moveClientX);
130+
fixture.detectChanges();
131+
132+
// Assert
133+
expect(getDrawerWidth()).toBe(MIN_WIDTH);
134+
135+
// Cleanup
136+
dispatchMouseEvent(document, 'mouseup', moveClientX);
137+
});
138+
139+
it('should not resize above max width', () => {
140+
// Arrange
141+
const startClientX = 100;
142+
const moveClientX = 9000; // Attempt to drag far right
143+
144+
// Act
145+
dispatchMouseEvent(resizeHandle, 'mousedown', startClientX);
146+
dispatchMouseEvent(document, 'mousemove', moveClientX);
147+
fixture.detectChanges();
148+
149+
// Assert
150+
expect(getDrawerWidth()).toBe(MAX_WIDTH);
151+
152+
// Cleanup
153+
dispatchMouseEvent(document, 'mouseup', moveClientX);
154+
});
155+
156+
it('should re-clamp width on window resize if current width exceeds new max width',
157+
() => {
158+
// Arrange
159+
expect(getDrawerWidth()).toBe(INITIAL_WIDTH); // 570
160+
const smallWindowWidth = 800;
161+
const expectedClampedWidth = smallWindowWidth / 2; // 400
162+
163+
// Act
164+
innerWidthSpy.and.returnValue(smallWindowWidth);
165+
window.dispatchEvent(new Event('resize'));
166+
fixture.detectChanges();
167+
168+
// Assert
169+
expect(getDrawerWidth()).toBe(expectedClampedWidth);
170+
});
171+
});

0 commit comments

Comments
 (0)