diff --git a/src/app/components/chat-panel/chat-panel.component.html b/src/app/components/chat-panel/chat-panel.component.html index 9cd2958a..0f65bc40 100644 --- a/src/app/components/chat-panel/chat-panel.component.html +++ b/src/app/components/chat-panel/chat-panel.component.html @@ -236,7 +236,10 @@
{{ i18n.expectedToolUsesLabel }}
- } @else if (message.actualFinalResponse) { + } @else if ( + message.actualFinalResponse != null || + message.expectedFinalResponse != null + ) {
{{ i18n.actualResponseLabel }}
{{ message.actualFinalResponse }}
diff --git a/src/app/components/chat-panel/chat-panel.component.spec.ts b/src/app/components/chat-panel/chat-panel.component.spec.ts index 37fab07b..33ec40e2 100644 --- a/src/app/components/chat-panel/chat-panel.component.spec.ts +++ b/src/app/components/chat-panel/chat-panel.component.spec.ts @@ -184,6 +184,27 @@ describe('ChatPanelComponent', () => { const canvas = fixture.debugElement.query(By.css('app-a2ui-canvas')); expect(canvas).toBeTruthy(); }); + + it( + 'should render failed eval response compare when actual response is empty', + async () => { + component.messages = [{ + role: 'bot', + evalStatus: 2, + failedMetric: 'response_match_score', + actualFinalResponse: '', + expectedFinalResponse: 'Expected eval response', + }]; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const compareContainer = + fixture.debugElement.query(By.css('.eval-compare-container')); + expect(compareContainer).toBeTruthy(); + expect(compareContainer.nativeElement.textContent) + .toContain('Expected eval response'); + }); }); it('should display loading bar if message isLoading', async () => { diff --git a/src/app/components/chat-panel/chat-panel.component.ts b/src/app/components/chat-panel/chat-panel.component.ts index 37a81977..fdb94cc6 100644 --- a/src/app/components/chat-panel/chat-panel.component.ts +++ b/src/app/components/chat-panel/chat-panel.component.ts @@ -30,12 +30,11 @@ import {MatProgressBarModule} from '@angular/material/progress-bar'; import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; import {MatTooltipModule} from '@angular/material/tooltip'; import {NgxJsonViewerModule} from 'ngx-json-viewer'; -import {EMPTY, merge, NEVER, of, Subject} from 'rxjs'; +import {defer, EMPTY, merge, NEVER, Subject} from 'rxjs'; import {catchError, filter, first, switchMap, tap} from 'rxjs/operators'; import {isComputerUseResponse, isVisibleComputerUseClick} from '../../core/models/ComputerUse'; import type {EvalCase} from '../../core/models/Eval'; -import {FunctionCall, FunctionResponse} from '../../core/models/types'; import {AGENT_SERVICE} from '../../core/services/interfaces/agent'; import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag'; import {SAFE_VALUES_SERVICE} from '../../core/services/interfaces/safevalues'; @@ -157,6 +156,10 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { this.featureFlagService.isManualStateUpdateEnabled(); readonly isBidiStreamingEnabledObs = this.featureFlagService.isBidiStreamingEnabled(); + readonly isInfinityMessageScrollingEnabled = + toSignal(this.featureFlagService.isInfinityMessageScrollingEnabled(), { + initialValue: false, + }); readonly canEditSession = signal(true); readonly isUserFeedbackEnabled = toSignal(this.featureFlagService.isFeedbackServiceEnabled()); @@ -169,24 +172,30 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { constructor() { effect(() => { const sessionName = this.sessionName(); - if (sessionName) { - this.nextPageToken = ''; - this.uiStateService - .lazyLoadMessages(sessionName, { - pageSize: 100, - pageToken: this.nextPageToken, - }) - .pipe(first()) - .subscribe(); + const isInfinityEnabled = this.isInfinityMessageScrollingEnabled(); + if (!sessionName || !isInfinityEnabled) { + return; } + + this.loadInitialMessagesPage(sessionName); }); } + private loadInitialMessagesPage(sessionName: string): void { + this.nextPageToken = ''; + defer(() => this.uiStateService.lazyLoadMessages(sessionName, { + pageSize: 100, + pageToken: this.nextPageToken, + })) + .pipe(first(), catchError(() => EMPTY)) + .subscribe(); + } + ngOnInit() { this.featureFlagService.isInfinityMessageScrollingEnabled() .pipe( first(), - filter((enabled) => enabled), + filter((enabled) => enabled === true), switchMap( () => merge( this.uiStateService.onNewMessagesLoaded().pipe( @@ -208,11 +217,11 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { } this.scrollHeight = element.scrollHeight; - return this.uiStateService - .lazyLoadMessages(this.sessionName(), { + return defer(() => this.uiStateService.lazyLoadMessages( + this.sessionName(), { pageSize: 100, pageToken: this.nextPageToken, - }) + })) .pipe(first(), catchError(() => NEVER)); })))), takeUntilDestroyed(this.destroyRef), diff --git a/src/app/components/chat/chat.component.spec.ts b/src/app/components/chat/chat.component.spec.ts index b22b9a3b..879d239a 100644 --- a/src/app/components/chat/chat.component.spec.ts +++ b/src/app/components/chat/chat.component.spec.ts @@ -585,6 +585,36 @@ describe('ChatComponent', () => { }); }); + describe('when synthetic eval session ID is provided in URL', () => { + const EVAL_SYNTHETIC_SESSION_ID = '___eval___session___case-1'; + + beforeEach(() => { + mockAgentService.listAppsResponse.next([TEST_APP_1_NAME]); + mockFeatureFlagService.isSessionUrlEnabledResponse.next(true); + mockActivatedRoute.snapshot!.queryParams = { + [APP_QUERY_PARAM]: TEST_APP_1_NAME, + [SESSION_QUERY_PARAM]: EVAL_SYNTHETIC_SESSION_ID, + }; + }); + + it('should create a new session instead of restoring from URL', + async () => { + mockSessionService.createSession.calls.reset(); + mockSessionService.getSession.calls.reset(); + mockFeatureFlagService.isApplicationSelectorEnabledResponse.next( + false); + fixture = TestBed.createComponent(ChatComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + expect(mockSessionService.getSession).not.toHaveBeenCalledWith( + USER_ID, TEST_APP_1_NAME, EVAL_SYNTHETIC_SESSION_ID); + expect(mockSessionService.createSession) + .toHaveBeenCalledWith(USER_ID, TEST_APP_1_NAME); + }); + }); + describe('when session in URL is not found', () => { beforeEach(async () => { mockActivatedRoute.snapshot!.queryParams = { @@ -768,6 +798,12 @@ describe('ChatComponent', () => { id: 'event-2', author: 'bot', content: {parts: [{text: 'bot response'}]}, + evalStatus: 2, + failedMetric: 'response_match_score', + evalScore: 0.4, + evalThreshold: 0.7, + actualFinalResponse: '', + expectedFinalResponse: 'Expected bot response', }, ], }; @@ -798,6 +834,18 @@ describe('ChatComponent', () => { })); }); + it('should preserve eval comparison fields on bot messages', () => { + expect(component.messages()[1]).toEqual(jasmine.objectContaining({ + role: 'bot', + evalStatus: 2, + failedMetric: 'response_match_score', + evalScore: 0.4, + evalThreshold: 0.7, + actualFinalResponse: '', + expectedFinalResponse: 'Expected bot response', + })); + }); + it('should call getTrace', () => { expect(mockEventService.getTrace) .toHaveBeenCalledWith(SESSION_1_ID); diff --git a/src/app/components/chat/chat.component.ts b/src/app/components/chat/chat.component.ts index e833fc55..64012dd7 100644 --- a/src/app/components/chat/chat.component.ts +++ b/src/app/components/chat/chat.component.ts @@ -20,7 +20,7 @@ import {HttpErrorResponse} from '@angular/common/http'; import {AfterViewInit, ChangeDetectorRef, Component, ElementRef, HostListener, inject, Injectable, OnDestroy, OnInit, Renderer2, signal, viewChild, WritableSignal} from '@angular/core'; import {toSignal} from '@angular/core/rxjs-interop'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {MatButton, MatFabButton} from '@angular/material/button'; +import {MatButton} from '@angular/material/button'; import {MatCard} from '@angular/material/card'; import {MatDialog} from '@angular/material/dialog'; import {MatDivider} from '@angular/material/divider'; @@ -86,6 +86,17 @@ const A2A_DATA_PART_START_TAG = ''; const A2A_DATA_PART_END_TAG = ''; const A2UI_MIME_TYPE = 'application/json+a2ui'; +interface EvalCompareFields { + evalStatus?: number; + failedMetric?: string; + evalScore?: number; + evalThreshold?: number; + actualInvocationToolUses?: any[]; + expectedInvocationToolUses?: any[]; + actualFinalResponse?: string; + expectedFinalResponse?: string; +} + function fixBase64String(base64: string): string { // Replace URL-safe characters if they exist base64 = base64.replace(/-/g, '+').replace(/_/g, '/'); @@ -119,6 +130,7 @@ class CustomPaginatorIntl extends MatPaginatorIntl { const BIDI_STREAMING_RESTART_WARNING = 'Restarting bidirectional streaming is not currently supported. Please refresh the page or start a new session.'; +const EVAL_SYNTHETIC_SESSION_PREFIX = '___eval___session___'; @Component({ selector: 'app-chat', @@ -141,7 +153,6 @@ const BIDI_STREAMING_RESTART_WARNING = MatSlideToggle, MatDivider, MatCard, - MatFabButton, ResizableBottomDirective, TraceEventComponent, AsyncPipe, @@ -438,12 +449,15 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { const queryParams = this.activatedRoute.snapshot.queryParams; const sessionUrl = queryParams['session']; const userUrl = queryParams['userId']; + const isEvalSyntheticSession = + typeof sessionUrl === 'string' && + sessionUrl.startsWith(EVAL_SYNTHETIC_SESSION_PREFIX); if (userUrl) { this.userId = userUrl; } - if (!sessionUrlEnabled || !sessionUrl) { + if (!sessionUrlEnabled || !sessionUrl || isEvalSyntheticSession) { this.createSessionAndReset(); return; @@ -973,14 +987,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { let message: any = { role, - evalStatus: e?.evalStatus, - failedMetric: e?.failedMetric, - evalScore: e?.evalScore, - evalThreshold: e?.evalThreshold, - actualInvocationToolUses: e?.actualInvocationToolUses, - expectedInvocationToolUses: e?.expectedInvocationToolUses, - actualFinalResponse: e?.actualFinalResponse, - expectedFinalResponse: e?.expectedFinalResponse, + ...this.mapEvalCompareFields(e), invocationIndex: invocationIndex !== undefined ? invocationIndex : undefined, finalResponsePartIndex: @@ -1092,6 +1099,27 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { return `data:${mimeType};base64,${fixedBase64Data}`; } + // Centralized mapper keeps eval compare fields consistent + // across streamed and hydrated message construction paths. + private mapEvalCompareFields(event: any): EvalCompareFields { + return { + evalStatus: event?.evalStatus, + failedMetric: event?.failedMetric, + evalScore: event?.evalScore, + evalThreshold: event?.evalThreshold, + actualInvocationToolUses: event?.actualInvocationToolUses, + expectedInvocationToolUses: event?.expectedInvocationToolUses, + actualFinalResponse: event?.actualFinalResponse, + expectedFinalResponse: event?.expectedFinalResponse, + }; + } + + private addEvalFieldsToMessage(event: any, message: any) { + if (message.role !== 'bot') return; + + Object.assign(message, this.mapEvalCompareFields(event)); + } + private processPartIntoMessage(part: any, event: any, message: any) { if (!part) return; @@ -1586,6 +1614,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { role: 'bot', eventId: event.id }; + this.addEvalFieldsToMessage(event, botMessage); partsToProcess.forEach((part: any) => { if (isA2aResponse && this.isA2uiDataPart(part)) { @@ -1654,6 +1683,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { role: 'bot', eventId: event.id }; + this.addEvalFieldsToMessage(event, botMessage); event.content?.parts?.forEach((part: any) => { this.processPartIntoMessage(part, event, botMessage); diff --git a/src/app/components/eval-tab/eval-tab.component.html b/src/app/components/eval-tab/eval-tab.component.html index 9660b004..6427dd0c 100644 --- a/src/app/components/eval-tab/eval-tab.component.html +++ b/src/app/components/eval-tab/eval-tab.component.html @@ -111,12 +111,12 @@ } @if (showEvalHistory()) {
- @for (evalResult of getEvalHistoryOfCurrentSetSorted(); track evalResult) { + @for (evalResult of evalHistorySorted; track evalResult.timestamp) {
-
{{ formatTimestamp(evalResult.timestamp) }}
+
{{ evalResult.formattedTimestamp }}
{{ getPassCountForCurrentResult(evalResult.evaluationResults.evaluationResults) }} {{ i18n.passedSuffix }} @if (getFailCountForCurrentResult(evalResult.evaluationResults.evaluationResults) > 0) { @@ -126,9 +126,9 @@
}
- @if (getEvalMetrics(evalResult)) { + @if (evalResult.metrics.length > 0) {
- @for (evalMetric of getEvalMetrics(evalResult); track evalMetric) { + @for (evalMetric of evalResult.metrics; track evalMetric.metricName) { {{ evalMetric.metricName }}: {{ evalMetric.threshold }} diff --git a/src/app/components/eval-tab/eval-tab.component.spec.ts b/src/app/components/eval-tab/eval-tab.component.spec.ts index b5372c12..97154546 100644 --- a/src/app/components/eval-tab/eval-tab.component.spec.ts +++ b/src/app/components/eval-tab/eval-tab.component.spec.ts @@ -20,7 +20,7 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { EvalTabComponent } from './eval-tab.component'; import { EvalService } from '../../core/services/eval.service'; import { SessionService } from '../../core/services/session.service'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute } from '@angular/router'; import { @@ -33,9 +33,10 @@ import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag' describe('EvalTabComponent', () => { let component: EvalTabComponent; let fixture: ComponentFixture; + let evalService: jasmine.SpyObj; beforeEach(async () => { - const evalService = jasmine.createSpyObj([ + evalService = jasmine.createSpyObj([ 'getEvalSets', 'listEvalCases', 'runEval', @@ -93,4 +94,185 @@ describe('EvalTabComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should build stable sorted eval history entries', () => { + evalService.listEvalResults.and.returnValue(of(['result-1', 'result-2'])); + evalService.getEvalResult.and.callFake((appName: string, evalResultId: string) => { + const creationTimestamp = + evalResultId === 'result-1' ? 1710000000 : 1720000000; + + return of({ + evalSetId: 'set-1', + creationTimestamp, + evalCaseResults: [{ + id: evalResultId, + evalId: `case-${evalResultId}`, + finalEvalStatus: 1, + evalMetricResults: [], + evalMetricResultPerInvocation: [], + sessionId: 'session-id', + sessionDetails: {}, + overallEvalMetricResults: [], + }], + } as any); + }); + + fixture.componentRef.setInput('appName', 'test-app'); + fixture.detectChanges(); + component.selectEvalSet('set-1'); + + expect(component['evalHistorySorted'].map((entry: any) => entry.timestamp)) + .toEqual(['1720000000', '1710000000']); + }); + + it('should toggle history card without errors after history refresh', () => { + fixture.componentRef.setInput('appName', 'test-app'); + fixture.detectChanges(); + + component['appEvaluationResults'] = { + 'test-app': { + 'set-1': { + '1710000000': {isToggled: false, evaluationResults: []}, + }, + }, + } as any; + component.selectEvalSet('set-1'); + + expect(() => component.toggleHistoryStatusCard('1710000000')).not.toThrow(); + expect(component.isEvaluationStatusCardToggled('1710000000')).toBeTrue(); + }); + + it('should keep successful eval history entries when one result fails', () => { + evalService.listEvalResults.and.returnValue(of(['result-1', 'result-2'])); + evalService.getEvalResult.and.callFake((appName: string, evalResultId: string) => { + if (evalResultId === 'result-2') { + return throwError(() => new Error('result failed')); + } + + return of({ + evalSetId: 'set-1', + creationTimestamp: 1710000000, + evalCaseResults: [{ + id: evalResultId, + evalId: `case-${evalResultId}`, + finalEvalStatus: 1, + evalMetricResults: [], + evalMetricResultPerInvocation: [], + sessionId: 'session-id', + sessionDetails: {}, + overallEvalMetricResults: [], + }], + } as any); + }); + + fixture.componentRef.setInput('appName', 'test-app'); + fixture.detectChanges(); + component.selectEvalSet('set-1'); + + expect(component['evalHistorySorted'].length).toBe(1); + expect(component['evalHistorySorted'][0].timestamp).toBe('1710000000'); + }); + + it('should safely ignore non-array eval result id responses', () => { + evalService.listEvalResults.and.returnValue(of({not: 'an array'} as any)); + + fixture.componentRef.setInput('appName', 'test-app'); + fixture.detectChanges(); + component.selectEvalSet('set-1'); + + expect(evalService.getEvalResult).not.toHaveBeenCalled(); + expect(component['evalHistorySorted']).toEqual([]); + }); + + it('should refresh history once after loading all eval results', () => { + evalService.listEvalResults.and.returnValue(of(['result-1', 'result-2'])); + evalService.getEvalResult.and.callFake((appName: string, evalResultId: string) => { + const creationTimestamp = + evalResultId === 'result-1' ? 1710000000 : 1720000000; + + return of({ + evalSetId: 'set-1', + creationTimestamp, + evalCaseResults: [{ + id: evalResultId, + evalId: `case-${evalResultId}`, + finalEvalStatus: 1, + evalMetricResults: [], + evalMetricResultPerInvocation: [], + sessionId: 'session-id', + sessionDetails: {}, + overallEvalMetricResults: [], + }], + } as any); + }); + const refreshSpy = + spyOn(component, 'refreshEvalHistorySorted').and.callThrough(); + + fixture.componentRef.setInput('appName', 'test-app'); + fixture.detectChanges(); + component.selectedEvalSet = 'set-1'; + refreshSpy.calls.reset(); + component['getEvaluationResult'](); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('treats listEvalResults 404 as empty history', () => { + evalService.listEvalResults.and.returnValue( + throwError(() => ({status: 404, statusText: 'Not Found'}))); + + fixture.componentRef.setInput('appName', 'test-app'); + fixture.detectChanges(); + component.selectEvalSet('set-1'); + + expect(evalService.getEvalResult).not.toHaveBeenCalled(); + expect(component['evalHistorySorted']).toEqual([]); + }); + + it('preserves existing eval results when listEvalResults fails non-404', () => { + evalService.listEvalResults.and.returnValue( + throwError(() => ({status: 500, statusText: 'Server Error'}))); + component['appEvaluationResults'] = { + 'test-app': { + 'set-1': { + '1710000000': { + isToggled: false, + evaluationResults: [{ + setId: 'set-1', + evalId: 'case-1', + finalEvalStatus: 1, + evalMetricResults: [], + overallEvalMetricResults: [], + sessionId: 'session-id', + sessionDetails: {}, + }], + }, + }, + }, + } as any; + component.selectedEvalSet = 'set-1'; + component['refreshEvalHistorySorted'](); + const previousHistory = [...component['evalHistorySorted']]; + + fixture.componentRef.setInput('appName', 'test-app'); + fixture.detectChanges(); + component['getEvaluationResult'](); + + expect(evalService.getEvalResult).not.toHaveBeenCalled(); + expect(component['appEvaluationResults']['test-app']['set-1']['1710000000']) + .toBeDefined(); + expect(component['evalHistorySorted']).toEqual(previousHistory); + }); + + it('hides eval tab when getEvalSet returns 404 regardless of statusText', () => { + const shouldShowTabSpy = spyOn(component.shouldShowTab, 'emit'); + evalService.getEvalSets.and.returnValue( + throwError(() => ({status: 404, statusText: 'Anything'}))); + + fixture.componentRef.setInput('appName', 'test-app'); + fixture.detectChanges(); + component.getEvalSet(); + + expect(shouldShowTabSpy).toHaveBeenCalledWith(false); + }); }); diff --git a/src/app/components/eval-tab/eval-tab.component.ts b/src/app/components/eval-tab/eval-tab.component.ts index 520d1261..0eb0e770 100644 --- a/src/app/components/eval-tab/eval-tab.component.ts +++ b/src/app/components/eval-tab/eval-tab.component.ts @@ -24,8 +24,8 @@ import {MatIcon} from '@angular/material/icon'; import {MatProgressSpinner} from '@angular/material/progress-spinner'; import {MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, MatTable, MatTableDataSource} from '@angular/material/table'; import {MatTooltip} from '@angular/material/tooltip'; -import {BehaviorSubject, of} from 'rxjs'; -import {catchError} from 'rxjs/operators'; +import {BehaviorSubject, forkJoin, Observable, of, throwError} from 'rxjs'; +import {catchError, map, switchMap} from 'rxjs/operators'; import {DEFAULT_EVAL_METRICS, EvalCase, EvalMetric, Invocation} from '../../core/models/Eval'; import {Session} from '../../core/models/Session'; @@ -78,6 +78,30 @@ interface AppEvaluationResult { [key: string]: SetEvaluationResult; } +interface EvalHistoryEntry { + timestamp: string; + formattedTimestamp: string; + evaluationResults: UIEvaluationResult; + metrics: EvalMetric[]; +} + +interface EvalResultApiCaseResult { + id: string; + evalId: string; + finalEvalStatus: number; + evalMetricResults: any[]; + evalMetricResultPerInvocation?: any[]; + sessionId: string; + sessionDetails: any; + overallEvalMetricResults?: any[]; +} + +interface EvalResultApiResponse { + evalSetId: string; + creationTimestamp: number|string; + evalCaseResults: EvalResultApiCaseResult[]; +} + @Component({ selector: 'app-eval-tab', templateUrl: './eval-tab.component.html', @@ -140,6 +164,7 @@ export class EvalTabComponent implements OnInit, OnChanges { readonly dialog = inject(MatDialog); protected appEvaluationResults: AppEvaluationResult = {}; + protected evalHistorySorted: EvalHistoryEntry[] = []; private readonly evalService = inject(EVAL_SERVICE); private readonly sessionService = inject(SESSION_SERVICE); @@ -156,14 +181,26 @@ export class EvalTabComponent implements OnInit, OnChanges { } ngOnChanges(changes: SimpleChanges): void { - if (changes['appName']) { - this.selectedEvalSet = ''; - this.evalCases = []; - this.getEvalSet(); - this.getEvaluationResult(); + if (changes['appName']?.currentValue) { + this.loadDataForApp(changes['appName'].currentValue); + } + } + ngOnInit(): void { + if (this.appName()) { + this.loadDataForApp(this.appName()); + } + } + + private loadDataForApp(appName: string) { + if (!appName) { + return; } + this.selectedEvalSet = ''; + this.evalCases = []; + this.evalHistorySorted = []; + this.getEvalSet(); + this.getEvaluationResult(); } - ngOnInit(): void {} selectNewEvalCase(evalCases: string[]) { let caseToSelect = this.deletedEvalCaseIndex; @@ -177,18 +214,23 @@ export class EvalTabComponent implements OnInit, OnChanges { if (this.appName() !== '') { this.evalService.getEvalSets(this.appName()) .pipe(catchError((error) => { - if (error.status === 404 && error.statusText === 'Not Found') { + if (error.status === 404) { this.shouldShowTab.emit(false); return of(null); } - return of([]); + return throwError(() => error); })) - .subscribe((sets) => { - if (sets !== null) { - this.shouldShowTab.emit(true); - this.evalsets = sets; - this.changeDetectorRef.detectChanges(); - } + .subscribe({ + next: (sets: any[]|null) => { + if (sets !== null) { + this.shouldShowTab.emit(true); + this.evalsets = sets; + this.changeDetectorRef.detectChanges(); + } + }, + error: () => { + // Preserve current tab state/data when fetch fails unexpectedly. + }, }); ; } @@ -263,12 +305,12 @@ export class EvalTabComponent implements OnInit, OnChanges { this.currentEvalResultBySet.set(this.selectedEvalSet, res); this.getEvaluationResult(); - this.changeDetectorRef.detectChanges(); }); } selectEvalSet(set: string) { this.selectedEvalSet = set; + this.refreshEvalHistorySorted(); this.listEvalCases(); } @@ -278,6 +320,7 @@ export class EvalTabComponent implements OnInit, OnChanges { return; } this.selectedEvalSet = ''; + this.evalHistorySorted = []; } isAllSelected() { @@ -398,19 +441,42 @@ export class EvalTabComponent implements OnInit, OnChanges { } protected getEvalHistoryOfCurrentSet() { - return this.appEvaluationResults[this.appName()][this.selectedEvalSet]; + const appEvalHistory = this.appEvaluationResults[this.appName()] ?? {}; + return appEvalHistory[this.selectedEvalSet] ?? {}; } - protected getEvalHistoryOfCurrentSetSorted(): any[] { + private refreshEvalHistorySorted() { const evalHistory = this.getEvalHistoryOfCurrentSet(); const evalHistorySorted = Object.keys(evalHistory).sort((a, b) => b.localeCompare(a)); - const evalHistorySortedArray = evalHistorySorted.map((key) => { - return {timestamp: key, evaluationResults: evalHistory[key]}; + this.evalHistorySorted = evalHistorySorted.map((key) => { + const evaluationResults = evalHistory[key]; + return { + timestamp: key, + formattedTimestamp: this.formatTimestamp(key), + evaluationResults, + metrics: this.resolveEvalMetricsForHistory(evaluationResults), + }; }); + } + + private resolveEvalMetricsForHistory( + uiEvaluationResult: UIEvaluationResult): EvalMetric[] { + const results = uiEvaluationResult.evaluationResults; + if (results.length === 0) { + return this.evalMetrics.map((metric) => ({...metric})); + } + + const overallEvalMetricResults = results[0].overallEvalMetricResults; + if (!overallEvalMetricResults || overallEvalMetricResults.length === 0) { + return this.evalMetrics.map((metric) => ({...metric})); + } - return evalHistorySortedArray; + return overallEvalMetricResults.map((result: any) => ({ + metricName: result.metricName, + threshold: result.threshold, + })); } protected getPassCountForCurrentResult(result: any[]) { @@ -503,61 +569,83 @@ export class EvalTabComponent implements OnInit, OnChanges { } protected getEvaluationResult() { - this.evalService.listEvalResults(this.appName()) + const appNameSnapshot = this.appName(); + this.evalService.listEvalResults(appNameSnapshot) .pipe(catchError((error) => { - if (error.status === 404 && error.statusText === 'Not Found') { - this.shouldShowTab.emit(false); - return of(null); + // No eval run history yet is valid; keep Eval tab visible. + if (error.status === 404) { + return of([]); } - return of([]); - })) - .subscribe((res) => { - for (const evalResultId of res) { - this.evalService.getEvalResult(this.appName(), evalResultId) - .subscribe((res) => { - if (!this.appEvaluationResults[this.appName()]) { - this.appEvaluationResults[this.appName()] = {}; + return throwError(() => error); + }), + switchMap((res): Observable> => { + if (!Array.isArray(res) || res.length === 0) { + return of([]); + } + const resultRequests: Array> = + res.map((evalResultId: string) => { + return this.evalService + .getEvalResult(appNameSnapshot, evalResultId) + .pipe( + map((evalResult) => + evalResult as EvalResultApiResponse), + catchError(() => of(null)), + ); + }); + return forkJoin(resultRequests); + }), + map((evalResults: Array) => { + const appEvalResults: SetEvaluationResult = {}; + for (const evalResult of evalResults) { + if (!evalResult) { + continue; } + this.mergeEvalResultIntoSet( + appEvalResults, evalResult as EvalResultApiResponse); + } + return appEvalResults; + })) + .subscribe({ + next: (appEvalResults: SetEvaluationResult) => { + this.appEvaluationResults[appNameSnapshot] = appEvalResults; + if (this.appName() === appNameSnapshot) { + this.refreshEvalHistorySorted(); + } + }, + error: () => { + // Preserve existing cached results when backend fails unexpectedly. + }, + }); + } - if (!this.appEvaluationResults[this.appName()] - [res.evalSetId]) { - this.appEvaluationResults[this.appName()][res.evalSetId] = - {}; - } - - const timeStamp = res.creationTimestamp; - - if (!this.appEvaluationResults[this.appName()][res.evalSetId] - [timeStamp]) { - this.appEvaluationResults[this.appName()][res.evalSetId][timeStamp] = - {isToggled: false, evaluationResults: []}; - } + private mergeEvalResultIntoSet( + appEvalResults: SetEvaluationResult, + evalResult: EvalResultApiResponse) { + if (!appEvalResults[evalResult.evalSetId]) { + appEvalResults[evalResult.evalSetId] = {}; + } + const timeStamp = evalResult.creationTimestamp; + appEvalResults[evalResult.evalSetId][timeStamp] = + this.mapEvalResultToUi(evalResult); + } - const uiEvaluationResult: UIEvaluationResult = { - isToggled: false, - evaluationResults: - res.evalCaseResults.map((result: any) => { - return { - setId: result.id, - evalId: result.evalId, - finalEvalStatus: result.finalEvalStatus, - evalMetricResults: result.evalMetricResults, - evalMetricResultPerInvocation: - result.evalMetricResultPerInvocation, - sessionId: result.sessionId, - sessionDetails: result.sessionDetails, - overallEvalMetricResults: - result.overallEvalMetricResults ?? [], - }; - }), - }; - - this.appEvaluationResults[this.appName()][res.evalSetId][timeStamp] = - uiEvaluationResult; - this.changeDetectorRef.detectChanges(); - }); - } - }); + private mapEvalResultToUi(evalResult: EvalResultApiResponse): + UIEvaluationResult { + return { + isToggled: false, + evaluationResults: evalResult.evalCaseResults.map((result) => { + return { + setId: result.id, + evalId: result.evalId, + finalEvalStatus: result.finalEvalStatus, + evalMetricResults: result.evalMetricResults, + evalMetricResultPerInvocation: result.evalMetricResultPerInvocation, + sessionId: result.sessionId, + sessionDetails: result.sessionDetails, + overallEvalMetricResults: result.overallEvalMetricResults ?? [], + }; + }), + }; } protected openEvalConfigDialog() { @@ -583,31 +671,4 @@ export class EvalTabComponent implements OnInit, OnChanges { }); } - protected getEvalMetrics(evalResult: any|undefined) { - if (!evalResult || !evalResult.evaluationResults || - !evalResult.evaluationResults.evaluationResults) { - return this.evalMetrics; - } - - const results = evalResult.evaluationResults.evaluationResults; - - if (results.length === 0) { - return this.evalMetrics; - } - - if (typeof results[0].overallEvalMetricResults === 'undefined' || - !results[0].overallEvalMetricResults || - results[0].overallEvalMetricResults.length === 0) { - return this.evalMetrics; - } - - const overallEvalMetricResults = results[0].overallEvalMetricResults; - - return overallEvalMetricResults.map((result: any) => { - return { - metricName: result.metricName, - threshold: result.threshold, - }; - }); - } } diff --git a/src/app/components/side-panel/side-panel.component.spec.ts b/src/app/components/side-panel/side-panel.component.spec.ts index 229e38b0..2423d7e0 100644 --- a/src/app/components/side-panel/side-panel.component.spec.ts +++ b/src/app/components/side-panel/side-panel.component.spec.ts @@ -414,6 +414,37 @@ describe('SidePanelComponent', () => { }); describe('Eval tab', () => { + it('initializes eval tab only once for the same container', () => { + const localFixture = TestBed.createComponent(SidePanelComponent); + const localComponent = localFixture.componentInstance; + localFixture.componentRef.setInput('appName', 'test-app'); + localFixture.componentRef.setInput('showSidePanel', true); + const initEvalTabSpy = + spyOn(localComponent, 'initEvalTab').and.callThrough(); + + localFixture.detectChanges(); + localFixture.detectChanges(); + + expect(initEvalTabSpy).toHaveBeenCalledTimes(1); + }); + + it('keeps dynamic eval tab inputs in sync', async () => { + await switchTab(EVAL_TAB_INDEX); + + fixture.componentRef.setInput('appName', 'updated-app'); + fixture.componentRef.setInput('userId', 'updated-user'); + fixture.componentRef.setInput('sessionId', 'updated-session'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const evalTab = fixture.debugElement.query(EVAL_TAB_SELECTOR) + .componentInstance as EvalTabComponent; + expect(evalTab.appName()).toBe('updated-app'); + expect(evalTab.userId()).toBe('updated-user'); + expect(evalTab.sessionId()).toBe('updated-session'); + }); + describe('Interactions', () => { beforeEach(async () => { await switchTab(EVAL_TAB_INDEX); diff --git a/src/app/components/side-panel/side-panel.component.ts b/src/app/components/side-panel/side-panel.component.ts index e35f4f02..d6fbac60 100644 --- a/src/app/components/side-panel/side-panel.component.ts +++ b/src/app/components/side-panel/side-panel.component.ts @@ -16,7 +16,7 @@ */ import {AsyncPipe, NgComponentOutlet, NgTemplateOutlet} from '@angular/common'; -import {AfterViewInit, Component, computed, effect, EnvironmentInjector, inject, input, output, runInInjectionContext, signal, Type, viewChild, ViewContainerRef, type WritableSignal} from '@angular/core'; +import {Component, ComponentRef, computed, effect, EnvironmentInjector, inject, input, output, signal, Type, viewChild, ViewContainerRef, type WritableSignal} from '@angular/core'; import {toObservable} from '@angular/core/rxjs-interop'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {MatMiniFabButton} from '@angular/material/button'; @@ -84,7 +84,7 @@ import {SidePanelMessagesInjectionToken} from './side-panel.component.i18n'; MatInput, ], }) -export class SidePanelComponent implements AfterViewInit { +export class SidePanelComponent { protected readonly Object = Object; appName = input(''); @@ -129,6 +129,9 @@ export class SidePanelComponent implements AfterViewInit { readonly evalTabComponent = viewChild(EvalTabComponent); readonly evalTabContainer = viewChild('evalTabContainer', {read: ViewContainerRef}); + private initializedEvalTabContainer: ViewContainerRef|undefined; + private readonly evalTabComponentRef = + signal|undefined>(undefined); readonly logoComponent: Type|null = inject(LOGO_COMPONENT, { optional: true, @@ -204,11 +207,27 @@ export class SidePanelComponent implements AfterViewInit { return artifacts; }); - ngAfterViewInit() { - // Wait one tick until the eval tab container is ready. - setTimeout(() => { - this.initEvalTab(); - }, 500); + constructor() { + // Initialize Eval tab only when its dynamic container becomes available. + effect(() => { + const container = this.evalTabContainer(); + if (!container || container === this.initializedEvalTabContainer) { + return; + } + this.initEvalTab(container); + this.initializedEvalTabContainer = container; + }); + + // Keep dynamic eval-tab inputs in sync without nesting effects. + effect(() => { + const evalTabComponent = this.evalTabComponentRef(); + if (!evalTabComponent) { + return; + } + evalTabComponent.setInput('appName', this.appName()); + evalTabComponent.setInput('userId', this.userId()); + evalTabComponent.setInput('sessionId', this.sessionId()); + }); } /** @@ -216,23 +235,15 @@ export class SidePanelComponent implements AfterViewInit { * ngComponentOutlet supports input/output bindings: * https://github.com/angular/angular/issues/63099 */ - private initEvalTab() { + private initEvalTab(container: ViewContainerRef) { this.isEvalEnabledObs.pipe(first()).subscribe((isEvalEnabled) => { if (isEvalEnabled) { - const evalTabComponent = this.evalTabContainer()?.createComponent( + const evalTabComponent = container.createComponent( this.evalTabComponentClass ?? EvalTabComponent, { environmentInjector: this.environmentInjector, }); if (!evalTabComponent) return; - - runInInjectionContext(this.environmentInjector, () => { - // Ensure inputs are updated dynamically using effect. - effect(() => { - evalTabComponent.setInput('appName', this.appName()); - evalTabComponent.setInput('userId', this.userId()); - evalTabComponent.setInput('sessionId', this.sessionId()); - }); - }); + this.evalTabComponentRef.set(evalTabComponent); evalTabComponent.instance.sessionSelected.subscribe( (session: Session) => { this.sessionSelected.emit(session);