From f85249f22ea669c4dc9f78bff7223d39d7ec5493 Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Tue, 28 Apr 2026 15:24:38 -0700 Subject: [PATCH 01/30] feat: add UI support to display agent transfer events --- .../components/event-content/event-content.component.html | 8 ++++++++ .../components/event-content/event-content.component.ts | 7 +++++++ src/app/core/models/UiEvent.ts | 4 ++++ src/app/core/models/types.ts | 1 + 4 files changed, 20 insertions(+) diff --git a/src/app/components/event-content/event-content.component.html b/src/app/components/event-content/event-content.component.html index 70ccd829..d43666f7 100644 --- a/src/app/components/event-content/event-content.component.html +++ b/src/app/components/event-content/event-content.component.html @@ -109,6 +109,14 @@ [tooltipTitle]="'Route'" > } + @if (uiEvent.transferToAgent) { + + } @if (hasWorkflowNodes()) { + + + + } } } diff --git a/src/app/components/event-content/event-content.component.scss b/src/app/components/event-content/event-content.component.scss index fe79309d..7a07721b 100644 --- a/src/app/components/event-content/event-content.component.scss +++ b/src/app/components/event-content/event-content.component.scss @@ -46,3 +46,29 @@ app-content-bubble + app-content-bubble { .function-calls-previews { width: 100%; } + +.function-response-chip-container { + display: inline-flex; + align-items: center; + margin: 5px; +} + +.function-response-chip-container .menu-trigger-btn { + visibility: hidden; + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.function-response-chip-container .menu-trigger-btn mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; +} + +.function-response-chip-container:hover .menu-trigger-btn { + visibility: visible; +} diff --git a/src/app/components/event-content/event-content.component.ts b/src/app/components/event-content/event-content.component.ts index 7f936d17..0ba57ac2 100644 --- a/src/app/components/event-content/event-content.component.ts +++ b/src/app/components/event-content/event-content.component.ts @@ -3,6 +3,10 @@ import {Component, EventEmitter, Input, Output, inject} from '@angular/core'; import {MatButtonModule} from '@angular/material/button'; import {MatIconModule} from '@angular/material/icon'; import {MatTooltipModule} from '@angular/material/tooltip'; +import {MatDialog} from '@angular/material/dialog'; +import {MatMenuModule} from '@angular/material/menu'; +import {FunctionResponse} from '../../core/models/types'; +import {EditJsonDialogComponent} from '../edit-json-dialog/edit-json-dialog.component'; import {AgentRunRequest} from '../../core/models/AgentRunRequest'; import {isComputerUseResponse, isVisibleComputerUseClick} from '../../core/models/ComputerUse'; @@ -31,6 +35,7 @@ import {ContentBubbleComponent} from '../content-bubble/content-bubble.component LongRunningResponseComponent, HoverInfoButtonComponent, ContentBubbleComponent, + MatMenuModule, ], }) export class EventContentComponent { @@ -65,10 +70,11 @@ export class EventContentComponent { @Output() readonly editFunctionArgs = new EventEmitter(); @Output() readonly clickEvent = new EventEmitter(); - @Output() readonly longRunningResponseComplete = new EventEmitter(); + @Output() readonly longRunningResponseComplete = new EventEmitter(); @Output() readonly agentStateClick = new EventEmitter<{event: Event, index: number}>(); protected readonly i18n = inject(ChatPanelMessagesInjectionToken); + private readonly dialog = inject(MatDialog); readonly Object = Object; readonly String = String; @@ -134,4 +140,47 @@ export class EventContentComponent { event.functionResponses?.some(response => response.id === callId && (response.response as any)?.status !== 'pending') ); } + + openSendAnotherResponseDialog(functionResponse: FunctionResponse) { + let functionCallEventId = ''; + const callId = functionResponse.id; + + if (callId) { + for (const event of this.uiEvents) { + if (event.functionCalls) { + const fc = event.functionCalls.find(c => c.id === callId); + if (fc) { + functionCallEventId = (fc as any).functionCallEventId || event.event?.id || ''; + break; + } + } + } + } + + const dialogRef = this.dialog.open(EditJsonDialogComponent, { + data: { + dialogHeader: 'Send Another Response', + functionName: functionResponse.name, + jsonContent: functionResponse.response + }, + width: '600px' + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const content = { + role: 'user', + parts: [{ + functionResponse: { + id: callId, + name: functionResponse.name, + response: result, + }, + }], + functionCallEventId: functionCallEventId + }; + this.longRunningResponseComplete.emit(content); + } + }); + } } diff --git a/src/app/components/event-tab/event-tab.component.ts b/src/app/components/event-tab/event-tab.component.ts index a16cb5b4..63e35a39 100644 --- a/src/app/components/event-tab/event-tab.component.ts +++ b/src/app/components/event-tab/event-tab.component.ts @@ -289,14 +289,15 @@ export class EventTabComponent { } }); + let prevForceGraphTab = false; effect(() => { + const force = this.forceGraphTab(); const event = this.selectedEvent(); - if (this.forceGraphTab()) { + if (force && !prevForceGraphTab) { this.selectedDetailTab = this.graphsAvailable() ? 'graph' : 'event'; } - - + prevForceGraphTab = force; }); } From 56a2e8d810fa5e39318c10af01a3536347ae46d6 Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Thu, 30 Apr 2026 13:54:07 -0700 Subject: [PATCH 03/30] refactor: replace custom hover component with static chip and directive for function response tooltips --- .../event-content.component.html | 22 ++++---- .../event-content.component.scss | 53 ++++++++++++++++--- .../event-content/event-content.component.ts | 2 + 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/app/components/event-content/event-content.component.html b/src/app/components/event-content/event-content.component.html index 92dd4858..bf4265a3 100644 --- a/src/app/components/event-content/event-content.component.html +++ b/src/app/components/event-content/event-content.component.html @@ -66,16 +66,18 @@ @if (isComputerUseResponse(functionResponse)) { } @else { -
- - +
+
+ check + {{ functionResponse.name }} + + +
+ -
+
+ + + + } +   + } + + @if (isExpandable()) { + + @if (isArray(json)) { + [ + @if (!isExpanded) { + ... + ] + } + } @else { + @if (!isExpanded) { + ... + } + } + + } @else { + + @if (isString(json)) { + "{{ json }}" + } @else if (isNumber(json)) { + {{ json }} + } @else if (isBoolean(json)) { + {{ json }} + } @else if (isNull(json)) { + null + } @else if (isUndefined(json)) { + undefined + } + + } +
+ + @if (isExpandable() && isExpanded) { +
+ @if (isArray(json)) { + @for (item of json; track $index; let last = $last) { + + } + } @else { + @for (k of getKeys(json); track k; let last = $last) { + + } + } + @if (isArray(json)) { +
]
+ } +
+ } +
diff --git a/src/app/components/custom-json-viewer/custom-json-viewer.component.scss b/src/app/components/custom-json-viewer/custom-json-viewer.component.scss new file mode 100644 index 00000000..2405ab68 --- /dev/null +++ b/src/app/components/custom-json-viewer/custom-json-viewer.component.scss @@ -0,0 +1,148 @@ +:host { + display: block; + font-family: var(--ngx-json-font-family, monospace); + font-size: var(--ngx-json-font-size, 13px); + line-height: 1.4; +} + +.segment { + margin: 2px 0; + display: block; +} + +.segment-header { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.segment-toggler { + cursor: pointer; + display: inline-block; + width: 0; + height: 0; + border-style: solid; + border-width: 5px 0 5px 6px; + border-color: transparent transparent transparent var(--mat-sys-outline); + margin-right: 8px; + transition: transform 0.15s ease; + + &.expanded { + transform: rotate(90deg); + } + + &:hover { + border-left-color: var(--mat-sys-primary); + } +} + +.segment-key { + color: var(--mat-sys-primary); + font-weight: normal; + cursor: pointer; +} + +.segment-separator { + color: var(--mat-sys-on-surface); +} + +.segment-space { + display: inline-block; + width: 4px; + user-select: none; +} + +.segment-value { + color: var(--mat-sys-on-surface); +} + +.bracket { + color: var(--mat-sys-outline); + font-weight: normal; +} + +.collapsed-summary { + color: var(--mat-sys-on-surface-variant); + font-size: 11px; + margin: 0 4px; +} + +.segment-children { + margin-left: 12px; + padding-left: 4px; +} + +.close-bracket { + display: block; +} + +/* MD Button styling */ +.md-btn { + border: none; + outline: none; + cursor: pointer; + font-family: 'Roboto', sans-serif; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + color: var(--mat-sys-primary); + background-color: var(--mat-sys-primary-container); + border-radius: 4px; + padding: 2px 6px; + margin-left: 4px; + margin-right: 2px; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease, background-color 0.2s ease, color 0.2s ease, transform 0.2s ease; + height: 16px; + + &:hover { + opacity: 1 !important; + transform: scale(1.05); + background-color: var(--mat-sys-primary); + color: var(--mat-sys-on-primary); + } +} + +.segment-header:hover { + .md-btn { + opacity: 0.5; + visibility: visible; + } +} + +/* Type highlighting classes */ +.segment-type-string { + color: var(--mat-sys-tertiary); + + .value-string { + white-space: pre-wrap; + word-break: break-word; + } +} + +.segment-type-number { + color: var(--mat-sys-error); +} + +.segment-type-boolean { + color: var(--mat-sys-secondary); +} + +.segment-type-null, .segment-type-undefined { + color: var(--mat-sys-outline); + font-style: italic; +} + +/* Dialog styling customization */ +::ng-deep .custom-md-dialog { + .mat-mdc-dialog-container { + border-radius: 12px !important; + border: 1px solid var(--mat-sys-outline-variant); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3) !important; + background-color: var(--mat-sys-surface-container-high) !important; + } +} diff --git a/src/app/components/custom-json-viewer/custom-json-viewer.component.ts b/src/app/components/custom-json-viewer/custom-json-viewer.component.ts new file mode 100644 index 00000000..8335391e --- /dev/null +++ b/src/app/components/custom-json-viewer/custom-json-viewer.component.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input, OnInit, inject } from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MarkdownComponent } from '../markdown/markdown.component'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'app-markdown-preview-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatIcon, + MatIconButton, + MarkdownComponent + ], + template: ` +
+

+ article + Markdown Preview - {{ data.key }} +

+ +
+ + + + `, + styles: [` + .md-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px 8px; + border-bottom: 1px solid var(--mat-sys-outline-variant); + } + .md-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 1.25rem; + font-weight: 500; + color: var(--mat-sys-on-surface); + } + .title-icon { + color: var(--mat-sys-primary); + } + .close-button { + color: var(--mat-sys-on-surface-variant); + } + .md-dialog-content { + padding: 24px; + min-width: 500px; + max-width: 80vw; + max-height: 70vh; + overflow-y: auto; + background-color: var(--mat-sys-surface-container-high); + color: var(--mat-sys-on-surface); + } + `] +}) +export class MarkdownPreviewDialogComponent { + dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA) as { key: string; value: string }; + + close(): void { + this.dialogRef.close(); + } +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'app-custom-json-viewer', + templateUrl: './custom-json-viewer.component.html', + styleUrls: ['./custom-json-viewer.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatTooltip, + MatDialogModule, + ], +}) +export class CustomJsonViewerComponent implements OnInit { + @Input() json: any; + @Input() key: string | number | undefined; + @Input() expanded = true; + @Input() depth = 0; + + private readonly dialog = inject(MatDialog); + + isExpanded = true; + + ngOnInit() { + this.isExpanded = this.expanded; + } + + isExpandable(): boolean { + return this.json !== null && typeof this.json === 'object'; + } + + isObject(val: any): boolean { + return val !== null && typeof val === 'object' && !Array.isArray(val); + } + + isArray(val: any): boolean { + return Array.isArray(val); + } + + isString(val: any): boolean { + return typeof val === 'string'; + } + + hasLineBreaks(val: any): boolean { + return typeof val === 'string' && val.includes('\n'); + } + + isNumber(val: any): boolean { + return typeof val === 'number'; + } + + isBoolean(val: any): boolean { + return typeof val === 'boolean'; + } + + isNull(val: any): boolean { + return val === null; + } + + isUndefined(val: any): boolean { + return val === undefined; + } + + getKeys(val: any): string[] { + if (!val) return []; + return Object.keys(val); + } + + getTypeClass(val: any): string { + if (this.isString(val)) return 'segment-type-string'; + if (this.isNumber(val)) return 'segment-type-number'; + if (this.isBoolean(val)) return 'segment-type-boolean'; + if (this.isNull(val)) return 'segment-type-null'; + return 'segment-type-undefined'; + } + + toggleExpand(event: Event): void { + event.stopPropagation(); + this.isExpanded = !this.isExpanded; + } + + openMarkdownDialog(key: string | number, value: string, event: Event): void { + event.stopPropagation(); + this.dialog.open(MarkdownPreviewDialogComponent, { + data: { key: key.toString(), value }, + width: '800px', + maxWidth: '90vw', + panelClass: 'custom-md-dialog' + }); + } +} diff --git a/src/app/components/event-tab/event-tab.component.html b/src/app/components/event-tab/event-tab.component.html index ecf2e5cc..73cc2fea 100644 --- a/src/app/components/event-tab/event-tab.component.html +++ b/src/app/components/event-tab/event-tab.component.html @@ -82,7 +82,7 @@ @if (selectedEvent()!.nodeInfo!['outputFor']) {
- + @@ -109,7 +109,7 @@ @if (isObject($any(selectedEvent()!.actions)[key])) {
- + @@ -130,7 +130,7 @@ {{ fc?.name }}
- + @@ -171,7 +171,7 @@
}
- + @@ -252,7 +252,7 @@ } @if (selectedDetailTab === 'raw') {
- + @@ -269,7 +269,7 @@
Old Value
@if (isObject(change.oldValue)) { - + } @else { {{ change.oldValue }} } @@ -279,7 +279,7 @@
New Value
@if (isObject(change.newValue)) { - + } @else { {{ change.newValue }} } @@ -392,7 +392,7 @@
Select an LLM response to see request details.
} @else {
- + @@ -408,7 +408,7 @@
Select an LLM response to see response details.
} @else {
- + diff --git a/src/app/components/event-tab/event-tab.component.ts b/src/app/components/event-tab/event-tab.component.ts index 63e35a39..3d455d85 100644 --- a/src/app/components/event-tab/event-tab.component.ts +++ b/src/app/components/event-tab/event-tab.component.ts @@ -8,7 +8,7 @@ import {MatProgressSpinner} from '@angular/material/progress-spinner'; import {MatTooltip} from '@angular/material/tooltip'; import {MatMenuModule, MatMenuTrigger} from '@angular/material/menu'; import {type SafeHtml} from '@angular/platform-browser'; -import {NgxJsonViewerModule} from 'ngx-json-viewer'; +import {CustomJsonViewerComponent} from '../custom-json-viewer/custom-json-viewer.component'; import {InfoTable} from '../info-table/info-table'; import {Event, Part} from '../../core/models/types'; @@ -34,7 +34,7 @@ import {addSvgNodeHoverEffects} from '../../utils/svg-interaction.utils'; MatProgressSpinner, MatTooltip, MatMenuModule, - NgxJsonViewerModule, + CustomJsonViewerComponent, InfoTable, ], }) From 764dc9441ec68ca26b150e8608b29a58868c7619 Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Fri, 8 May 2026 09:54:24 -0700 Subject: [PATCH 10/30] style: update json string color to use css variable with fallback --- .../custom-json-viewer/custom-json-viewer.component.scss | 2 +- src/styles.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/custom-json-viewer/custom-json-viewer.component.scss b/src/app/components/custom-json-viewer/custom-json-viewer.component.scss index 2405ab68..83a4c812 100644 --- a/src/app/components/custom-json-viewer/custom-json-viewer.component.scss +++ b/src/app/components/custom-json-viewer/custom-json-viewer.component.scss @@ -116,7 +116,7 @@ /* Type highlighting classes */ .segment-type-string { - color: var(--mat-sys-tertiary); + color: var(--ngx-json-string, #FF6B6B); .value-string { white-space: pre-wrap; diff --git a/src/styles.scss b/src/styles.scss index 9d6b4a5e..8778ce18 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -142,7 +142,7 @@ ngx-json-viewer { } .segment-type-string { - color: var(--mat-sys-tertiary) !important; + color: var(--ngx-json-string, #FF6B6B) !important; .segment-value { white-space: pre-wrap !important; From 018c38da4d5ddb1532726f677640a98558482234 Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Fri, 8 May 2026 10:02:11 -0700 Subject: [PATCH 11/30] refactor: replace ngx-json-viewer with CustomJsonViewerComponent throughout the application and add markdown rendering support --- package-lock.json | 10 ------- package.json | 1 - .../chat-panel/chat-panel.component.ts | 3 +- src/app/components/chat/chat.component.ts | 3 +- .../content-bubble.component.html | 12 ++++---- .../content-bubble.component.ts | 4 +-- .../custom-json-viewer.component.html | 10 +++---- .../custom-json-viewer.component.scss | 8 ++++- .../custom-json-viewer.component.ts | 1 + .../event-tab/event-tab.component.html | 18 +++++------ .../json-tooltip/json-tooltip.component.ts | 8 ++--- .../long-running-response.html | 4 +-- .../long-running-response.ts | 4 +-- .../state-tab/state-tab.component.html | 2 +- .../state-tab/state-tab.component.ts | 4 +-- .../trace-event/trace-event.component.html | 6 ++-- .../trace-event/trace-event.component.ts | 4 +-- .../trace-tab/trace-tab.component.html | 2 +- .../trace-tab/trace-tab.component.ts | 4 +-- src/styles.scss | 30 ------------------- 20 files changed, 51 insertions(+), 87 deletions(-) diff --git a/package-lock.json b/package-lock.json index 86d4b6f6..ebc37649 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "@viz-js/viz": "^3.12.0", "codemirror": "^6.0.2", "mermaid": "^11.14.0", - "ngx-json-viewer": "^3.2.1", "ngx-markdown": "^21.0.1", "ngx-vflow": "^1.16.4", "rxjs": "~7.8.0", @@ -13124,15 +13123,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ngx-json-viewer": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ngx-json-viewer/-/ngx-json-viewer-3.2.1.tgz", - "integrity": "sha512-TTHtXsrBX+IXPqqAIsxklHPqSNmyGeQaziFZbCDJq1PnPOQmTrEHfwNrzN3LnWGhf7UxeM1cK0njegVPChwEcg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - } - }, "node_modules/ngx-markdown": { "version": "21.1.0", "resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-21.1.0.tgz", diff --git a/package.json b/package.json index 4244bf46..b0b86ab2 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "@viz-js/viz": "^3.12.0", "codemirror": "^6.0.2", "mermaid": "^11.14.0", - "ngx-json-viewer": "^3.2.1", "ngx-markdown": "^21.0.1", "ngx-vflow": "^1.16.4", "rxjs": "~7.8.0", diff --git a/src/app/components/chat-panel/chat-panel.component.ts b/src/app/components/chat-panel/chat-panel.component.ts index 00e6acb6..7c602f57 100644 --- a/src/app/components/chat-panel/chat-panel.component.ts +++ b/src/app/components/chat-panel/chat-panel.component.ts @@ -33,7 +33,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatTabsModule } from '@angular/material/tabs'; import { MatSelectModule } from '@angular/material/select'; -import { NgxJsonViewerModule } from 'ngx-json-viewer'; +import { CustomJsonViewerComponent } from '../custom-json-viewer/custom-json-viewer.component'; import { EMPTY, merge, NEVER, of, Subject } from 'rxjs'; import { catchError, filter, first, switchMap, tap } from 'rxjs/operators'; @@ -110,7 +110,6 @@ export type DisplayItem = { MatMenuModule, MatProgressSpinnerModule, MatSlideToggleModule, - NgxJsonViewerModule, MatTooltipModule, MatButtonToggleModule, MatTabsModule, diff --git a/src/app/components/chat/chat.component.ts b/src/app/components/chat/chat.component.ts index 4d0935e2..0a6093dd 100644 --- a/src/app/components/chat/chat.component.ts +++ b/src/app/components/chat/chat.component.ts @@ -36,7 +36,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { MatToolbar } from '@angular/material/toolbar'; import { SafeHtml } from '@angular/platform-browser'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; -import { NgxJsonViewerModule } from 'ngx-json-viewer'; +import { CustomJsonViewerComponent } from '../custom-json-viewer/custom-json-viewer.component'; import { combineLatest, firstValueFrom, Observable, of } from 'rxjs'; import { catchError, distinctUntilChanged, filter, first, map, shareReplay, startWith, switchMap, take, tap } from 'rxjs/operators'; @@ -154,7 +154,6 @@ const BIDI_STREAMING_RESTART_WARNING = FormsModule, ReactiveFormsModule, MatIcon, - NgxJsonViewerModule, MatButton, MatIconButton, MatMenuModule, diff --git a/src/app/components/content-bubble/content-bubble.component.html b/src/app/components/content-bubble/content-bubble.component.html index 118b79d2..9df472e4 100644 --- a/src/app/components/content-bubble/content-bubble.component.html +++ b/src/app/components/content-bubble/content-bubble.component.html @@ -185,11 +185,11 @@ @if (uiEvent.actualInvocationToolUses) {
{{ i18n.actualToolUsesLabel }}
- +
{{ i18n.expectedToolUsesLabel }}
- +
} @else if (uiEvent.actualFinalResponse) {
@@ -213,17 +213,17 @@
} } @else if (type === 'output') { - + > } @else if (type === 'error') { - + > } @else if (type === 'transcription') { @if (role === 'user' && uiEvent.event.inputTranscription) { {{ uiEvent.event.inputTranscription.text }} diff --git a/src/app/components/content-bubble/content-bubble.component.ts b/src/app/components/content-bubble/content-bubble.component.ts index 50a8abbc..4997811a 100644 --- a/src/app/components/content-bubble/content-bubble.component.ts +++ b/src/app/components/content-bubble/content-bubble.component.ts @@ -4,7 +4,7 @@ import {CommonModule} from '@angular/common'; import {FormsModule} from '@angular/forms'; import {MatIconModule} from '@angular/material/icon'; import {MatTooltipModule} from '@angular/material/tooltip'; -import {NgxJsonViewerModule} from 'ngx-json-viewer'; +import {CustomJsonViewerComponent} from '../custom-json-viewer/custom-json-viewer.component'; import {UiEvent} from '../../core/models/UiEvent'; import {SAFE_VALUES_SERVICE} from '../../core/services/interfaces/safevalues'; @@ -25,7 +25,7 @@ import { ARTIFACT_SERVICE } from '../../core/services/interfaces/artifact'; FormsModule, MatIconModule, MatTooltipModule, - NgxJsonViewerModule, + CustomJsonViewerComponent, A2uiCanvasComponent, AudioPlayerComponent, JsonTooltipDirective, diff --git a/src/app/components/custom-json-viewer/custom-json-viewer.component.html b/src/app/components/custom-json-viewer/custom-json-viewer.component.html index 1305c176..d892e236 100644 --- a/src/app/components/custom-json-viewer/custom-json-viewer.component.html +++ b/src/app/components/custom-json-viewer/custom-json-viewer.component.html @@ -1,13 +1,13 @@
- @if (isExpandable()) { + @if (isExpandable() && depth > 0) { } @if (key !== undefined) { {{ key }} : - @if (hasLineBreaks(json)) { + @if (showMarkdown && hasLineBreaks(json)) { @@ -47,14 +47,14 @@
@if (isExpandable() && isExpanded) { -
+
@if (isArray(json)) { @for (item of json; track $index; let last = $last) { - + } } @else { @for (k of getKeys(json); track k; let last = $last) { - + } } @if (isArray(json)) { diff --git a/src/app/components/custom-json-viewer/custom-json-viewer.component.scss b/src/app/components/custom-json-viewer/custom-json-viewer.component.scss index 83a4c812..9ae57503 100644 --- a/src/app/components/custom-json-viewer/custom-json-viewer.component.scss +++ b/src/app/components/custom-json-viewer/custom-json-viewer.component.scss @@ -12,7 +12,7 @@ .segment-header { display: flex; - align-items: center; + align-items: flex-start; flex-wrap: wrap; } @@ -25,6 +25,7 @@ border-width: 5px 0 5px 6px; border-color: transparent transparent transparent var(--mat-sys-outline); margin-right: 8px; + margin-top: 4px; transition: transform 0.15s ease; &.expanded { @@ -70,6 +71,11 @@ .segment-children { margin-left: 12px; padding-left: 4px; + + &.root-children { + margin-left: 0; + padding-left: 0; + } } .close-bracket { diff --git a/src/app/components/custom-json-viewer/custom-json-viewer.component.ts b/src/app/components/custom-json-viewer/custom-json-viewer.component.ts index 8335391e..487f9869 100644 --- a/src/app/components/custom-json-viewer/custom-json-viewer.component.ts +++ b/src/app/components/custom-json-viewer/custom-json-viewer.component.ts @@ -108,6 +108,7 @@ export class CustomJsonViewerComponent implements OnInit { @Input() key: string | number | undefined; @Input() expanded = true; @Input() depth = 0; + @Input() showMarkdown = false; private readonly dialog = inject(MatDialog); diff --git a/src/app/components/event-tab/event-tab.component.html b/src/app/components/event-tab/event-tab.component.html index 73cc2fea..14662f33 100644 --- a/src/app/components/event-tab/event-tab.component.html +++ b/src/app/components/event-tab/event-tab.component.html @@ -82,7 +82,7 @@ @if (selectedEvent()!.nodeInfo!['outputFor']) {
- + @@ -109,7 +109,7 @@ @if (isObject($any(selectedEvent()!.actions)[key])) {
- + @@ -130,7 +130,7 @@ {{ fc?.name }}
- + @@ -171,7 +171,7 @@
}
- + @@ -252,7 +252,7 @@ } @if (selectedDetailTab === 'raw') {
- + @@ -269,7 +269,7 @@
Old Value
@if (isObject(change.oldValue)) { - + } @else { {{ change.oldValue }} } @@ -279,7 +279,7 @@
New Value
@if (isObject(change.newValue)) { - + } @else { {{ change.newValue }} } @@ -392,7 +392,7 @@
Select an LLM response to see request details.
} @else {
- + @@ -408,7 +408,7 @@
Select an LLM response to see response details.
} @else {
- + diff --git a/src/app/components/json-tooltip/json-tooltip.component.ts b/src/app/components/json-tooltip/json-tooltip.component.ts index 517a4eef..fe9f391b 100644 --- a/src/app/components/json-tooltip/json-tooltip.component.ts +++ b/src/app/components/json-tooltip/json-tooltip.component.ts @@ -15,7 +15,7 @@ * limitations under the License. */ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { NgxJsonViewerModule } from 'ngx-json-viewer'; +import { CustomJsonViewerComponent } from '../custom-json-viewer/custom-json-viewer.component'; @Component({ changeDetection: ChangeDetectionStrategy.Default, @@ -26,7 +26,7 @@ import { NgxJsonViewerModule } from 'ngx-json-viewer';
{{ title }}
}
- +
`, @@ -64,14 +64,14 @@ import { NgxJsonViewerModule } from 'ngx-json-viewer'; background: inherit; z-index: 1; } - ngx-json-viewer { + app-custom-json-viewer { display: block; height: auto !important; min-width: 0; } `], standalone: true, - imports: [NgxJsonViewerModule], + imports: [CustomJsonViewerComponent], }) export class JsonTooltipComponent { @Input() title: string = ''; diff --git a/src/app/components/long-running-response/long-running-response.html b/src/app/components/long-running-response/long-running-response.html index 1d8ac910..30410605 100644 --- a/src/app/components/long-running-response/long-running-response.html +++ b/src/app/components/long-running-response/long-running-response.html @@ -44,7 +44,7 @@
Payload
- +
\ No newline at end of file +
diff --git a/src/app/components/event-tab/event-tab.component.spec.ts b/src/app/components/event-tab/event-tab.component.spec.ts index 32abd80d..a911514a 100644 --- a/src/app/components/event-tab/event-tab.component.spec.ts +++ b/src/app/components/event-tab/event-tab.component.spec.ts @@ -23,7 +23,7 @@ import {MatDialog} from '@angular/material/dialog'; import {MatListHarness} from '@angular/material/list/testing'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; -import {Span} from '../../core/models/Trace'; +import {Span, SpanValidator} from '../../core/models/Trace'; import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag'; import {TRACE_SERVICE} from '../../core/services/interfaces/trace'; import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state'; @@ -34,8 +34,21 @@ import {MockUiStateService} from '../../core/services/testing/mock-ui-state.serv import {EventTabComponent} from './event-tab.component'; import {TraceChartComponent} from './trace-chart/trace-chart.component'; +/** + * Helper that builds a `Span` (optionally with `children`) by routing the + * envelope through `SpanValidator` so promoted `attr*` fields are + * populated and the raw `attributes` bag is dropped. + */ +function makeSpan(raw: unknown, children: Span[] = []): Span { + const result = SpanValidator.safeParse(raw); + if (!result.success) { + throw new Error(`Failed to build test span: ${result.error.message}`); + } + return children.length > 0 ? {...result.data, children} : result.data; +} + const MOCK_TRACE_DATA: Span[] = [ - { + makeSpan({ name: 'agent.act', start_time: 1733084700000000000, end_time: 1733084760000000000, @@ -47,41 +60,44 @@ const MOCK_TRACE_DATA: Span[] = [ 'gcp.vertex.agent.llm_request': '{"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."}]}]}', }, - }, - { - name: 'tool.invoke', - start_time: 1733084705000000000, - end_time: 1733084755000000000, - span_id: 'span-2', - parent_span_id: 'span-1', - trace_id: 'trace-1', - attributes: { - 'tool_name': 'project_helper', - }, - children: [ + }), + makeSpan( { - name: 'sub-tool-1.invoke', - start_time: 1733084710000000000, - end_time: 1733084750000000000, - span_id: 'span-3', - parent_span_id: 'span-2', + name: 'tool.invoke', + start_time: 1733084705000000000, + end_time: 1733084755000000000, + span_id: 'span-2', + parent_span_id: 'span-1', trace_id: 'trace-1', attributes: { - 'sub_tool_name': 'sub_project_helper_1', + 'tool_name': 'project_helper', }, - children: [ + }, + [makeSpan( { - name: 'sub-tool-2.invoke', - start_time: 1733084715000000000, - end_time: 1733084745000000000, - span_id: 'span-4', - parent_span_id: 'span-3', + name: 'sub-tool-1.invoke', + start_time: 1733084710000000000, + end_time: 1733084750000000000, + span_id: 'span-3', + parent_span_id: 'span-2', trace_id: 'trace-1', attributes: { - 'sub_tool_name': 'sub_project_helper_2', + 'sub_tool_name': 'sub_project_helper_1', }, - children: [ + }, + [makeSpan( { + name: 'sub-tool-2.invoke', + start_time: 1733084715000000000, + end_time: 1733084745000000000, + span_id: 'span-4', + parent_span_id: 'span-3', + trace_id: 'trace-1', + attributes: { + 'sub_tool_name': 'sub_project_helper_2', + }, + }, + [makeSpan({ name: 'sub-tool-3.invoke', start_time: 1733084720000000000, end_time: 1733084740000000000, @@ -91,15 +107,8 @@ const MOCK_TRACE_DATA: Span[] = [ attributes: { 'sub_tool_name': 'sub_project_helper_3', }, - children: [], - }, - ], - }, - ], - }, - ], - } -] as Span[]; + })])])]), +]; const MOCK_EVENTS_MAP = new Map([ ['event1', {title: 'Event 1 Title'}], diff --git a/src/app/components/event-tab/event-tab.component.ts b/src/app/components/event-tab/event-tab.component.ts index 7fa558b9..e5398860 100644 --- a/src/app/components/event-tab/event-tab.component.ts +++ b/src/app/components/event-tab/event-tab.component.ts @@ -31,9 +31,15 @@ import {InfoTable} from '../info-table/info-table'; import {Event, Part} from '../../core/models/types'; import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state'; import {SidePanelMessagesInjectionToken} from '../side-panel/side-panel.component.i18n'; -import {SpanNode} from '../../core/models/Trace'; +import {Span} from '../../core/models/Trace'; import {TRACE_SERVICE} from '../../core/services/interfaces/trace'; import {addSvgNodeHoverEffects} from '../../utils/svg-interaction.utils'; +export type SpanNode = Span & { + children: SpanNode[]; + depth: number; + duration: number; + id: string; // Using span_id as string ID +}; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -65,7 +71,7 @@ export class EventTabComponent { readonly rawSvgString = input(null); readonly llmRequest = input(); readonly llmResponse = input(); - readonly traceData = input([]); + readonly traceData = input([]); readonly appName = input(''); readonly selectedEventGraphPath = input(''); readonly hasSubWorkflows = input(false); @@ -160,10 +166,10 @@ export class EventTabComponent { readonly associatedSpans = computed(() => { const ev = this.selectedEvent(); if (!ev || !ev.id) return []; - + const allSpans = this.traceData(); if (!allSpans) return []; - + const flatten = (arr: any[]): any[] => { let result: any[] = []; for (const item of arr) { @@ -174,9 +180,9 @@ export class EventTabComponent { } return result; }; - + const flatSpans = flatten(allSpans); - return flatSpans.filter(s => s.attributes && s.attributes['gcp.vertex.agent.event_id'] === ev.id); + return flatSpans.filter(s => s.attrEventId === ev.id); }); readonly sessionUsageMetadata = computed(() => { @@ -206,11 +212,11 @@ export class EventTabComponent { }); private _selectedDetailTab: 'event' | 'raw' | 'request' | 'response' | 'graph' | 'metadata' | 'state' = 'event'; - + get selectedDetailTab() { return this._selectedDetailTab; } - + set selectedDetailTab(tab: 'event' | 'raw' | 'request' | 'response' | 'graph' | 'metadata' | 'state') { this._selectedDetailTab = tab; window.localStorage.setItem('adk-event-tab-selected-tab', tab); @@ -310,7 +316,7 @@ export class EventTabComponent { effect(() => { const force = this.forceGraphTab(); const event = this.selectedEvent(); - + if (force && !prevForceGraphTab) { this.selectedDetailTab = this.graphsAvailable() ? 'graph' : 'event'; } @@ -337,7 +343,7 @@ export class EventTabComponent { if (targetInvocationId) { allEvents = allEvents.filter(ev => ev.invocationId === targetInvocationId); } - + const travelsForNode: any[][] = []; let currentTravel: any[] = []; let lastNodeName = ''; @@ -364,7 +370,7 @@ export class EventTabComponent { } else { evGraphPath = segments.slice(1, -1).join('/'); } - + if (evGraphPath === this.selectedEventGraphPath()) { const fullSegments = np.split('/'); const fullEvNodeName = fullSegments[fullSegments.length - 1]; @@ -377,7 +383,7 @@ export class EventTabComponent { lastNodeName = currentName; currentTravel = []; } - + if (currentName === nodeName) { currentTravel.push(ev); } diff --git a/src/app/components/event-tab/invoc-id.pipe.ts b/src/app/components/event-tab/invoc-id.pipe.ts index 237f7818..6eb10377 100644 --- a/src/app/components/event-tab/invoc-id.pipe.ts +++ b/src/app/components/event-tab/invoc-id.pipe.ts @@ -27,10 +27,11 @@ export class InvocIdPipe implements PipeTransform { if (!spans) { return undefined; } - return spans.find( - (item) => - item.attributes !== undefined && - 'gcp.vertex.agent.invocation_id' in item.attributes, - )?.attributes['gcp.vertex.agent.invocation_id']; + for (const span of spans) { + if (span.attrInvocationId !== undefined) { + return span.attrInvocationId; + } + } + return undefined; } } diff --git a/src/app/components/side-panel/side-panel.component.ts b/src/app/components/side-panel/side-panel.component.ts index a1793b5b..92145411 100644 --- a/src/app/components/side-panel/side-panel.component.ts +++ b/src/app/components/side-panel/side-panel.component.ts @@ -27,7 +27,7 @@ import {first} from 'rxjs/operators'; import {EvalCase} from '../../core/models/Eval'; import {Session, SessionState} from '../../core/models/Session'; -import {SpanNode} from '../../core/models/Trace'; +import {Span} from '../../core/models/Trace'; import {Blob, Event, LlmRequest, LlmResponse} from '../../core/models/types'; import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag'; import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state'; @@ -69,7 +69,7 @@ export class SidePanelComponent implements AfterViewInit, OnInit { appName = input(''); userId = input(''); sessionId = input(''); - traceData = input([]); + traceData = input([]); eventData = input(new Map()); currentSessionState = input(); artifacts = input([]); @@ -204,7 +204,7 @@ export class SidePanelComponent implements AfterViewInit, OnInit { switchToEvalTab() { this.isEvalEnabledObs.pipe(first()).subscribe((isEvalEnabled) => { if (!isEvalEnabled) return; - + forkJoin([ this.isArtifactsTabEnabledObs.pipe(first()), this.isTestsEnabledObs.pipe(first()), diff --git a/src/app/components/state-tab/state-tab.component.spec.ts b/src/app/components/state-tab/state-tab.component.spec.ts index d0ca4808..215917a4 100644 --- a/src/app/components/state-tab/state-tab.component.spec.ts +++ b/src/app/components/state-tab/state-tab.component.spec.ts @@ -66,7 +66,8 @@ describe('StateTabComponent', () => { 'sessionState', {foo: 'bar'} as unknown as SessionState); fixture.detectChanges(); - const jsonViewer = fixture.debugElement.query(By.css('app-custom-json-viewer')); + const jsonViewer = + fixture.debugElement.query(By.css('app-custom-json-viewer')); expect(jsonViewer).toBeTruthy(); const emptyState = fixture.debugElement.query(By.css('.empty-state')); diff --git a/src/app/components/trace-tab/trace-event/trace-event.component.spec.ts b/src/app/components/trace-tab/trace-event/trace-event.component.spec.ts index 71e444f5..7957fab9 100644 --- a/src/app/components/trace-tab/trace-event/trace-event.component.spec.ts +++ b/src/app/components/trace-tab/trace-event/trace-event.component.spec.ts @@ -22,7 +22,7 @@ import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {of} from 'rxjs'; // 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it} -import {Span} from '../../../core/models/Trace'; +import {Span, SpanValidator} from '../../../core/models/Trace'; import {EVENT_SERVICE, EventService} from '../../../core/services/interfaces/event'; import {FEATURE_FLAG_SERVICE} from '../../../core/services/interfaces/feature-flag'; import {GRAPH_SERVICE, GraphService} from '../../../core/services/interfaces/graph'; @@ -61,16 +61,28 @@ describe('TraceEventComponent', () => { let featureFlagService: MockFeatureFlagService; let uiStateService: MockUiStateService; - const span: Span = { - name: 'test-span', - trace_id: 'trace-id', - span_id: 'span-id', - start_time: 1, - end_time: 2, - attributes: { - 'gcp.vertex.agent.event_id': EVENT_ID, - }, - }; + /** + * Builds a `Span` by routing the raw object through `SpanValidator` so + * promoted attribute fields (e.g. `attrEventId`) are populated. + */ + function makeSpan(attrs: Record): Span { + const result = SpanValidator.safeParse({ + name: 'test-span', + trace_id: 'trace-id', + span_id: 'span-id', + start_time: 1, + end_time: 2, + attributes: attrs, + }); + if (!result.success) { + throw new Error(`Failed to build test span: ${result.error.message}`); + } + return result.data; + } + + const span: Span = makeSpan({ + 'gcp.vertex.agent.event_id': EVENT_ID, + }); beforeEach(async () => { traceService = new MockTraceService(); @@ -100,7 +112,6 @@ describe('TraceEventComponent', () => { .configureTestingModule({ imports: [TraceEventComponent, NoopAnimationsModule], providers: [ - {provide: MatDialog, useValue: matDialog}, {provide: TRACE_SERVICE, useValue: traceService}, {provide: EVENT_SERVICE, useValue: eventService}, {provide: GRAPH_SERVICE, useValue: graphService}, @@ -156,24 +167,18 @@ describe('TraceEventComponent', () => { }); it('should parse LLM request from the selected span attributes', () => { - traceService.selectedTraceRow$.next({ - ...span, - attributes: { - 'gcp.vertex.agent.event_id': EVENT_ID, - 'gcp.vertex.agent.llm_request': JSON.stringify({data: 'request'}), - } - }); + traceService.selectedTraceRow$.next(makeSpan({ + 'gcp.vertex.agent.event_id': EVENT_ID, + 'gcp.vertex.agent.llm_request': JSON.stringify({data: 'request'}), + })); expect(component.llmRequest).toEqual({data: 'request'}); }); it('should parse LLM response from the selected span attributes', () => { - traceService.selectedTraceRow$.next({ - ...span, - attributes: { - 'gcp.vertex.agent.event_id': EVENT_ID, - 'gcp.vertex.agent.llm_response': JSON.stringify({data: 'response'}), - } - }); + traceService.selectedTraceRow$.next(makeSpan({ + 'gcp.vertex.agent.event_id': EVENT_ID, + 'gcp.vertex.agent.llm_response': JSON.stringify({data: 'response'}), + })); expect(component.llmResponse).toEqual({data: 'response'}); }); }); @@ -190,14 +195,7 @@ describe('TraceEventComponent', () => { it('should return undefined if the selected row lacks the event_id attribute', () => { - component.selectedRow = { - name: 'test-span', - trace_id: 'trace-id', - span_id: 'span-id', - start_time: 1, - end_time: 2, - attributes: {'another_attribute': 'value'}, - }; + component.selectedRow = makeSpan({'another_attribute': 'value'}); expect(component.getEventIdFromSpan()).toBeUndefined(); }); }); diff --git a/src/app/components/trace-tab/trace-event/trace-event.component.ts b/src/app/components/trace-tab/trace-event/trace-event.component.ts index 7a1ca79d..b2ad8104 100644 --- a/src/app/components/trace-tab/trace-event/trace-event.component.ts +++ b/src/app/components/trace-tab/trace-event/trace-event.component.ts @@ -26,7 +26,7 @@ import {SafeHtml} from '@angular/platform-browser'; import {CustomJsonViewerComponent} from '../../custom-json-viewer/custom-json-viewer.component'; import {tap} from 'rxjs/operators'; -import {Span} from '../../../core/models/Trace'; +import {OPERATION_GENERATE_CONTENT, Span} from '../../../core/models/Trace'; import {EVENT_SERVICE} from '../../../core/services/interfaces/event'; import {FEATURE_FLAG_SERVICE} from '../../../core/services/interfaces/feature-flag'; import {GRAPH_SERVICE} from '../../../core/services/interfaces/graph'; @@ -58,8 +58,6 @@ export class TraceEventComponent implements OnInit { llmRequest: any = undefined; llmResponse: any = undefined; - llmRequestKey = 'gcp.vertex.agent.llm_request'; - llmResponseKey = 'gcp.vertex.agent.llm_response'; private readonly dialog = inject(MatDialog); private readonly traceService = inject(TRACE_SERVICE); @@ -80,27 +78,9 @@ export class TraceEventComponent implements OnInit { this.selectedRow = span; const eventId = this.getEventIdFromSpan(); if (eventId) { - this.llmRequest = undefined; - this.llmResponse = undefined; - - const requestStr = this.selectedRow?.attributes?.[this.llmRequestKey]; - const responseStr = this.selectedRow?.attributes?.[this.llmResponseKey]; - - if (requestStr) { - try { - this.llmRequest = typeof requestStr === 'string' ? JSON.parse(requestStr) : requestStr; - } catch (e) { - console.warn('Failed to parse LLM request', e); - } - } - - if (responseStr) { - try { - this.llmResponse = typeof responseStr === 'string' ? JSON.parse(responseStr) : responseStr; - } catch (e) { - console.warn('Failed to parse LLM response', e); - } - } + const io = this.selectedRow?.io; + this.llmRequest = io?.inputs; + this.llmResponse = io?.outputs; this.getEventGraph(eventId); } }); @@ -119,17 +99,15 @@ export class TraceEventComponent implements OnInit { getEventDetails() { if (this.eventData && this.selectedRow) { - return this.eventData.get(this.getEventIdFromSpan()); + const eventId = this.getEventIdFromSpan(); + return eventId ? this.eventData.get(eventId) : undefined; } else { return undefined; } } getEventIdFromSpan() { - if (!this.selectedRow) { - return undefined; - } - return this.selectedRow.attributes['gcp.vertex.agent.event_id']; + return this.selectedRow?.attrEventId; } getEventGraph(eventId: string) { diff --git a/src/app/components/trace-tab/trace-tab.component.html b/src/app/components/trace-tab/trace-tab.component.html index 4f90f8e7..d9757609 100644 --- a/src/app/components/trace-tab/trace-tab.component.html +++ b/src/app/components/trace-tab/trace-tab.component.html @@ -78,11 +78,12 @@ } - @if (selectedSpan()?.attributes && selectedSpan()!.attributes['gcp.vertex.agent.event_id']) { + @let eventId = getSelectedSpanEventId(); + @if (eventId) { - +
Event ID{{ selectedSpan()!.attributes['gcp.vertex.agent.event_id'] }}{{ eventId }}
} @@ -91,10 +92,11 @@ } @if (selectedDetailTab() === 'attributes') {
- @if (selectedSpan()?.attributes && Object.keys(selectedSpan()!.attributes).length > 0) { + @let attrs = getSelectedSpanAttributesView(); + @if (attrs && Object.keys(attrs).length > 0) { - @for (key of Object.keys(selectedSpan()!.attributes); track key) { - + @for (key of Object.keys(attrs); track key) { + }
{{ key }}{{ selectedSpan()!.attributes[key] }}
{{ key }}{{ attrs[key] }}
} @else { @@ -103,9 +105,10 @@
} @if (selectedDetailTab() === 'raw') { + @let rawSpan = getSelectedSpanRawView();
- -
diff --git a/src/app/components/trace-tab/trace-tab.component.spec.ts b/src/app/components/trace-tab/trace-tab.component.spec.ts index cc08b5b7..77f094ef 100644 --- a/src/app/components/trace-tab/trace-tab.component.spec.ts +++ b/src/app/components/trace-tab/trace-tab.component.spec.ts @@ -20,14 +20,27 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {MatExpansionPanelHarness} from '@angular/material/expansion/testing'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; -import {Span} from '../../core/models/Trace'; +import {Span, SpanValidator} from '../../core/models/Trace'; import {TRACE_SERVICE} from '../../core/services/interfaces/trace'; import {MockTraceService} from './../../core/services/testing/mock-trace.service'; import {TraceTabComponent} from './trace-tab.component'; +/** + * Helper that builds a `Span` by routing a raw OTel-shaped object through + * `SpanValidator`. Required because the validator strips the raw + * `attributes` bag in favor of typed `attr*` promoted fields. + */ +function makeSpan(raw: unknown): Span { + const result = SpanValidator.safeParse(raw); + if (!result.success) { + throw new Error(`Failed to build test span: ${result.error.message}`); + } + return result.data; +} + const MOCK_TRACE_DATA: Span[] = [ - { + makeSpan({ name: 'agent.act', start_time: 1733084700000000000, end_time: 1733084760000000000, @@ -39,8 +52,8 @@ const MOCK_TRACE_DATA: Span[] = [ 'gcp.vertex.agent.llm_request': '{"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."}]}]}', }, - }, - { + }), + makeSpan({ name: 'tool.invoke', start_time: 1733084705000000000, end_time: 1733084755000000000, @@ -50,7 +63,7 @@ const MOCK_TRACE_DATA: Span[] = [ attributes: { 'tool_name': 'project_helper', }, - }, + }), ]; describe('TraceTabComponent', () => { @@ -89,7 +102,7 @@ describe('TraceTabComponent', () => { xdescribe('with trace data', () => { const MOCK_TRACE_DATA_WITH_MULTIPLE_TRACES: Span[] = [ ...MOCK_TRACE_DATA, - { + makeSpan({ name: 'agent.act-2', start_time: 1733084700000000000, end_time: 1733084760000000000, @@ -101,7 +114,7 @@ describe('TraceTabComponent', () => { 'gcp.vertex.agent.llm_request': '{"contents":[{"role":"user","parts":[{"text":"Another user message"}]}]}', }, - }, + }), ]; beforeEach(async () => { diff --git a/src/app/components/trace-tab/trace-tab.component.ts b/src/app/components/trace-tab/trace-tab.component.ts index 668d0ee1..c800f958 100644 --- a/src/app/components/trace-tab/trace-tab.component.ts +++ b/src/app/components/trace-tab/trace-tab.component.ts @@ -24,6 +24,7 @@ import {CustomJsonViewerComponent} from '../custom-json-viewer/custom-json-viewe import {InfoTable} from '../info-table/info-table'; import {TRACE_SERVICE} from '../../core/services/interfaces/trace'; +import {Span} from '../../core/models/Trace'; @Injectable() export class SpanPaginatorIntl extends MatPaginatorIntl { @@ -56,28 +57,28 @@ export class SpanPaginatorIntl extends MatPaginatorIntl { ] }) export class TraceTabComponent { - _traceData: any[] = []; - orderedTraceData: any[] = []; + _traceData: Span[] = []; + orderedTraceData: Span[] = []; // Input kept so we don't break side-panel binding, though not used here anymore - @Input() set traceData(val: any[]) { + @Input() set traceData(val: Span[]) { this._traceData = val || []; this.orderedTraceData = this.computeOrdered(this._traceData); } - get traceData(): any[] { + get traceData(): Span[] { return this._traceData; } - computeOrdered(spans: any[]): any[] { + computeOrdered(spans: Span[]): Span[] { const spanClones = spans.map(span => ({...span})); - const spanMap = new Map(); - const roots: any[] = []; + const spanMap = new Map(); + const roots: Span[] = []; - spanClones.forEach(span => spanMap.set(span.span_id, span)); + spanClones.forEach(span => spanMap.set(String(span.span_id), span)); spanClones.forEach(span => { - if (span.parent_span_id && spanMap.has(span.parent_span_id)) { - const parent = spanMap.get(span.parent_span_id)!; + if (span.parent_span_id && spanMap.has(String(span.parent_span_id))) { + const parent = spanMap.get(String(span.parent_span_id))!; parent.children = parent.children || []; parent.children.push(span); } else { @@ -85,7 +86,7 @@ export class TraceTabComponent { } }); - const flatten = (spansArray: any[]): any[] => { + const flatten = (spansArray: Span[]): Span[] => { return spansArray.flatMap(span => [ span, ...(span.children ? flatten(span.children) : []) @@ -94,10 +95,10 @@ export class TraceTabComponent { return flatten(roots); } - + protected readonly traceService = inject(TRACE_SERVICE); selectedSpan = toSignal(this.traceService.selectedTraceRow$); - + private static getValidTraceTab(tab: string | null): 'info' | 'attributes' | 'raw' { if (tab === 'info' || tab === 'attributes' || tab === 'raw') { return tab; @@ -108,7 +109,7 @@ export class TraceTabComponent { selectedDetailTab = signal<'info' | 'attributes' | 'raw'>( TraceTabComponent.getValidTraceTab(window.localStorage.getItem('adk-trace-tab-selected-tab')) ); - + switchToEvent = output(); constructor() { @@ -122,14 +123,14 @@ export class TraceTabComponent { return new Date(nanos / 1_000_000).toLocaleString(); } - get selectedSpanChildren() { + get selectedSpanChildren(): Span[] { const span = this.selectedSpan(); if (!span) return []; if (span.children && span.children.length > 0) return span.children; - return this.traceData.filter(s => s.parent_span_id === span.span_id); + return this.traceData.filter(s => s.parent_span_id && String(s.parent_span_id) === String(span.span_id)); } - selectSpanById(id: string | undefined): void { + selectSpanById(id: string | number | null | undefined): void { if (!id) return; const span = this.traceData.find(s => String(s.span_id) === String(id)); if (span) { @@ -174,19 +175,34 @@ export class TraceTabComponent { this.traceService.selectedRow(this.orderedTraceData[newIndex]); } - + readonly Object = Object; copiedId: string | null = null; - copyToClipboard(value: string | undefined | null, key?: string) { - if (!value) return; - navigator.clipboard.writeText(value).then(() => { - this.copiedId = key || value; + copyToClipboard(value: string | number | undefined | null, key?: string) { + if (value === undefined || value === null || value === '') return; + const strValue = String(value); + navigator.clipboard.writeText(strValue).then(() => { + this.copiedId = key || strValue; setTimeout(() => this.copiedId = null, 2000); }); } + getSelectedSpanEventId(): string | undefined { + return this.selectedSpan()?.attrEventId; + } + + getSelectedSpanAttributesView(): Record { + const span = this.selectedSpan(); + return span?.rawAttributesUseThisFieldOnlyForDisplay ?? {}; + } + + getSelectedSpanRawView(): unknown { + const span = this.selectedSpan(); + return span?.rawSpanUseThisFieldOnlyForDisplay; + } + copyJsonToClipboard(json: any, key: string) { if (!json) return; const value = JSON.stringify(json, null, 2); diff --git a/src/app/components/trace-tab/trace-tree/trace-tree.component.ts b/src/app/components/trace-tab/trace-tree/trace-tree.component.ts index f7eae55a..02d03d59 100644 --- a/src/app/components/trace-tab/trace-tree/trace-tree.component.ts +++ b/src/app/components/trace-tab/trace-tree/trace-tree.component.ts @@ -25,6 +25,11 @@ import {UiEvent} from '../../../core/models/UiEvent'; import {HtmlTooltipDirective} from '../../../directives/html-tooltip.directive'; import {EventContentComponent} from '../../event-content/event-content.component'; +export interface FlatTreeNode { + span: Span; + level: number; +} + @Component({ changeDetection: ChangeDetectionStrategy.Default, selector: 'app-trace-tree', @@ -33,7 +38,7 @@ import {EventContentComponent} from '../../event-content/event-content.component imports: [MatButtonModule, MatIconModule, MatTooltipModule, HtmlTooltipDirective, EventContentComponent] }) export class TraceTreeComponent implements OnInit, OnChanges { - @Input() spans: any[] = []; + @Input() spans: Span[] = []; @Input() invocationId: string = ''; @Input() uiEvents: UiEvent[] = []; @Input() shouldShowEvent?: (uiEvent: UiEvent) => boolean; @@ -42,9 +47,9 @@ export class TraceTreeComponent implements OnInit, OnChanges { baseStartTimeMs = 0; totalDurationMs = 1; rootLatencyNanos = 0; - flatTree: {span: Span; level: number}[] = []; + flatTree: FlatTreeNode[] = []; - shouldShowNode(node: any): boolean { + shouldShowNode(node: FlatTreeNode): boolean { const uiEvent = this.getUiEvent(node); if (!uiEvent) { return true; @@ -121,11 +126,11 @@ export class TraceTreeComponent implements OnInit, OnChanges { this.flatTree.push(...this.flattenTree(root.children, 0)); } }); - + const times = this.getGlobalTimes(this.spans); this.baseStartTimeMs = times.start; this.totalDurationMs = times.duration; - + if (this.tree && this.tree.length > 0) { this.rootLatencyNanos = this.tree[0].end_time - this.tree[0].start_time; } else { @@ -139,10 +144,10 @@ export class TraceTreeComponent implements OnInit, OnChanges { const spanMap = new Map(); const roots: Span[] = []; - spanClones.forEach(span => spanMap.set(span.span_id, span)); + spanClones.forEach(span => spanMap.set(String(span.span_id), span)); spanClones.forEach(span => { - if (span.parent_span_id && spanMap.has(span.parent_span_id)) { - const parent = spanMap.get(span.parent_span_id)!; + if (span.parent_span_id && spanMap.has(String(span.parent_span_id))) { + const parent = spanMap.get(String(span.parent_span_id))!; parent.children = parent.children || []; parent.children.push(span); } else { @@ -169,7 +174,7 @@ export class TraceTreeComponent implements OnInit, OnChanges { if (nanos < 1_000_000) return `${(nanos / 1000).toFixed(2)}us`; if (nanos < 1_000_000_000) return `${(nanos / 1_000_000).toFixed(2)}ms`; if (nanos < 60_000_000_000) return `${(nanos / 1_000_000_000).toFixed(2)}s`; - + const minutes = Math.floor(nanos / 60_000_000_000); const seconds = ((nanos % 60_000_000_000) / 1_000_000_000).toFixed(2); return `${minutes}m ${seconds}s`; @@ -187,7 +192,7 @@ export class TraceTreeComponent implements OnInit, OnChanges { 100; } - flattenTree(spans: Span[], level: number = 0): any[] { + flattenTree(spans: Span[], level: number = 0): FlatTreeNode[] { const tree = spans.flatMap( span => [{span, level}, @@ -222,34 +227,31 @@ export class TraceTreeComponent implements OnInit, OnChanges { return Array.from({length: n}); } - selectRow(node: any) { + selectRow(node: FlatTreeNode) { if (this.selectedRow && this.selectedRow.span_id == node.span.span_id) { return; } this.traceService.selectedRow(node.span); } - rowSelected(node: any) { + rowSelected(node: FlatTreeNode) { if (!this.selectedRow || !node?.span) return false; return String(this.selectedRow.span_id) === String(node.span.span_id); } - isEventRow(node: any) { - if (!node.span.attributes) { - return false; - } - const eventId = node?.span.attributes['gcp.vertex.agent.event_id']; + isEventRow(node: FlatTreeNode) { + const eventId = this.getEventId(node); if (eventId && this.uiEvents && this.uiEvents.length > 0) { return this.uiEvents.some(e => e.event?.id === eventId); } return false; } - getEventId(node: any): string { - return node?.span?.attributes?.['gcp.vertex.agent.event_id'] ?? ''; + getEventId(node: FlatTreeNode): string { + return node?.span?.attrEventId ?? ''; } - getUiEvent(node: any): UiEvent | null { + getUiEvent(node: FlatTreeNode): UiEvent | null { const eventId = this.getEventId(node); if (eventId && this.uiEvents && this.uiEvents.length > 0) { return this.uiEvents.find(e => e.event?.id === eventId) || null; diff --git a/src/app/core/models/Trace.ts b/src/app/core/models/Trace.ts index 44bc357d..939da50e 100644 --- a/src/app/core/models/Trace.ts +++ b/src/app/core/models/Trace.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,50 +15,527 @@ * limitations under the License. */ -// Non-exhaustive union for better autocomplete. -export declare type LogName = 'gen_ai.user.message' | 'gen_ai.choice' | 'gen_ai.system.message' | (string & {}); +/** + * Public facade for trace / span validation. + * + * Validation is performed in three stages: + * + * Stage 1 (`RawSpanValidator`) — validates the OpenTelemetry envelope + * (`name`, `start_time`, …, plus a lenient internal `attributes` bag + * used only as the source for promotion). + * + * Stage 2 (`SpanValidator`) — promotes well-known attributes from the + * bag into typed top-level fields prefixed with `attr`. All + * non-display logic should read attribute values through the typed + * `attr*` promoted fields. + * + * All known attributes are surfaced as **optional** on every span. On + * top of that, two operation-specific branches tighten certain + * attributes to **required**: + * + * - `gen_ai.operation.name == "invoke_agent"` → + * `attrConversationId` becomes required. + * - `gen_ai.operation.name == "generate_content"` → + * `attrEventId` and `attrInvocationId` become required. + * + * `attrOperationName` is the discriminator. On the *fallback* branch + * (operation name missing or unrecognized) `attrOperationName` is the + * literal `undefined`, so a check like + * + * if (span.attrOperationName === 'generate_content') { … } + * + * narrows the union to `GenerateContentSpan` purely via TypeScript's + * discriminated-union machinery — no extra runtime helper needed. + * + * Stage 3 (input/output coercion) — see {@link coerceSpanIo}. Projects + * the span's inputs/outputs into a unified discriminated `SpanIo` + * shape so that consumers can render LLM IO uniformly regardless of + * which semantic convention produced the underlying data: + * + * - experimental: payload from the + * `gen_ai.client.inference.operation.details` log's attributes; + * - stable: payload from `gen_ai.system.message`, + * `gen_ai.user.message`, and `gen_ai.choice` child logs; + * - legacy: payload from the `gcp.vertex.agent.llm_{request,response}` + * JSON-string attributes. + * + * The result is exposed as the optional `io` field on + * `GenerateContentSpan` and `FallbackSpan` (the only branches that + * can carry LLM IO). Note: child log records are validated at Stage 1 + * (so malformed shapes are still rejected) but are dropped from the + * validated output — the only meaningful reason to read them was to + * derive the LLM IO, which is now centralized in Stage 3. + */ + +import { z } from 'zod'; + +import { + CompletionDetailsLog, + CompletionDetailsLogValidator, + GEN_AI_COMPLETION_DETAILS_EVENT, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + ResponseFinishReasonsAttrValidator, + UsageTokensAttrValidator, +} from './trace/ExperimentalSemconv'; +import { oTelAnyValueSchema } from './trace/Shared'; +import { coerceSpanIo, SpanIo } from './trace/SpanIo'; +import { + LogValidator as StableLogValidator, + ValidatedLog as StableValidatedLog, +} from './trace/StableSemconv'; + +// --------------------------------------------------------------------------- +// Unified log validator — accepts both stable-semconv log events +// (`gen_ai.system.message`, `gen_ai.user.message`, `gen_ai.choice`) and the +// experimental-semconv completion-details event +// (`gen_ai.client.inference.operation.details`). +// +// Spans validated under either semantic convention may carry a mix of these +// log records (e.g. a `generate_content` span emits stable system/user/choice +// logs when stable semconv is in effect, OR a single completion-details log +// when the experimental opt-in is enabled). Validating both shapes here lets +// `RawSpanValidator.logs` accept either flavor without per-branch logic. +// --------------------------------------------------------------------------- + +const LogValidator = z.union([ + StableLogValidator, + CompletionDetailsLogValidator, +]); + +export { + GEN_AI_CHOICE_EVENT, + GEN_AI_SYSTEM_MESSAGE_EVENT, + GEN_AI_USER_MESSAGE_EVENT, + isChoiceLog, + isSystemMessageLog, + isUserMessageLog, +} from './trace/StableSemconv'; +export type { + GenAIContent, + PromptResponseLog, + SystemMessageLog, +} from './trace/StableSemconv'; + +export type ValidatedLog = StableValidatedLog | CompletionDetailsLog; + +export { + FUNCTION_TOOL_DEFINITION_TYPE, + GEN_AI_COMPLETION_DETAILS_EVENT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, +} from './trace/ExperimentalSemconv'; +export type { + CompletionDetailsLog, + FunctionToolDefinition, + GenericToolDefinition, + InputMessage, + OutputMessage, + Part, + ToolDefinition, +} from './trace/ExperimentalSemconv'; + +export { coerceSpanIo } from './trace/SpanIo'; +export type { + ExperimentalSpanIo, + LegacySpanIo, + SpanIo, + StableSpanIo, +} from './trace/SpanIo'; -export declare interface Log { - body: {[key: string]: any} | string; - event_name: LogName; - trace_id: string; - span_id: string; +/** Type guard for the experimental completion-details log event. */ +export function isCompletionDetailsLog( + log: ValidatedLog, +): log is CompletionDetailsLog { + return log.event_name === GEN_AI_COMPLETION_DETAILS_EVENT; } -export declare interface Span { +// --------------------------------------------------------------------------- +// Operation-name and ADK-specific attribute key constants. +// --------------------------------------------------------------------------- + +export const GEN_AI_OPERATION_NAME = 'gen_ai.operation.name'; +export const GEN_AI_CONVERSATION_ID = 'gen_ai.conversation.id'; +export const GEN_AI_AGENT_NAME = 'gen_ai.agent.name'; +export const GEN_AI_AGENT_DESCRIPTION = 'gen_ai.agent.description'; + +export const GCP_VERTEX_AGENT_INVOCATION_ID = + 'gcp.vertex.agent.invocation_id'; +export const GCP_VERTEX_AGENT_ASSOCIATED_EVENT_IDS = + 'gcp.vertex.agent.associated_event_ids'; +export const GCP_VERTEX_AGENT_EVENT_ID = 'gcp.vertex.agent.event_id'; +export const GCP_VERTEX_AGENT_LLM_REQUEST = 'gcp.vertex.agent.llm_request'; +export const GCP_VERTEX_AGENT_LLM_RESPONSE = 'gcp.vertex.agent.llm_response'; + +export const OPERATION_INVOKE_AGENT = 'invoke_agent'; +export const OPERATION_GENERATE_CONTENT = 'generate_content'; + +// --------------------------------------------------------------------------- +// Stage 1: Raw span envelope. +// --------------------------------------------------------------------------- + +const RawSpanValidator = z.object({ + name: z.string(), + start_time: z.number(), + end_time: z.number(), + trace_id: z.union([z.string(), z.number()]), + span_id: z.union([z.string(), z.number()]), + parent_span_id: z.union([z.string(), z.number()]).nullable().optional(), + attributes: z.record(z.string(), oTelAnyValueSchema).optional(), + logs: z.array(LogValidator).optional(), +}); + +type RawSpan = z.infer; + +// --------------------------------------------------------------------------- +// Promoted-attribute mixin. +// +// Every known attribute is validated as *optional* here. Per-operation +// schemas below tighten the relevant ones to required. +// +// Each entry validates the wire form of the attribute (e.g. arrays of +// experimental Part shapes, or JSON-string-encoded equivalents). Validation +// errors propagate as Zod issues regardless of which operation_name branch +// the span landed in. +// --------------------------------------------------------------------------- + +const PromotedAttrsMixin = z.object({ + attrConversationId: z.string().optional(), + attrInvocationId: z.string().optional(), + attrAssociatedEventIds: z.array(z.string()).optional(), + attrAgentName: z.string().optional(), + attrAgentDescription: z.string().optional(), + attrEventId: z.string().optional(), + attrResponseFinishReasons: ResponseFinishReasonsAttrValidator.optional(), + attrUsageInputTokens: UsageTokensAttrValidator.optional(), + attrUsageOutputTokens: UsageTokensAttrValidator.optional(), +}); + +/** + * The list of (wire-key, promoted-field) pairs we promote out of a raw + * span's `attributes` bag. Used by {@link buildPromotedAttrsCandidate} + */ +const PROMOTED_ATTRIBUTE_KEYS = [ + [GEN_AI_CONVERSATION_ID, 'attrConversationId'], + [GCP_VERTEX_AGENT_INVOCATION_ID, 'attrInvocationId'], + [GCP_VERTEX_AGENT_ASSOCIATED_EVENT_IDS, 'attrAssociatedEventIds'], + [GEN_AI_AGENT_NAME, 'attrAgentName'], + [GEN_AI_AGENT_DESCRIPTION, 'attrAgentDescription'], + [GCP_VERTEX_AGENT_EVENT_ID, 'attrEventId'], + [GEN_AI_RESPONSE_FINISH_REASONS, 'attrResponseFinishReasons'], + [GEN_AI_USAGE_INPUT_TOKENS, 'attrUsageInputTokens'], + [GEN_AI_USAGE_OUTPUT_TOKENS, 'attrUsageOutputTokens'], +] as const; + +/** + * Builds the promoted-attribute candidate object from a raw span's + * attributes bag. Keys not listed in {@link PROMOTED_ATTRIBUTE_KEYS} are + * dropped from the *promoted* fields. + */ +function buildPromotedAttrsCandidate(raw: RawSpan): Record { + const a = raw.attributes ?? {}; + const candidate: Record = { + attrConversationId: a[GEN_AI_CONVERSATION_ID], + attrInvocationId: a[GCP_VERTEX_AGENT_INVOCATION_ID], + attrAssociatedEventIds: a[GCP_VERTEX_AGENT_ASSOCIATED_EVENT_IDS], + attrAgentName: a[GEN_AI_AGENT_NAME], + attrAgentDescription: a[GEN_AI_AGENT_DESCRIPTION], + attrEventId: a[GCP_VERTEX_AGENT_EVENT_ID], + attrResponseFinishReasons: a[GEN_AI_RESPONSE_FINISH_REASONS], + attrUsageInputTokens: a[GEN_AI_USAGE_INPUT_TOKENS], + attrUsageOutputTokens: a[GEN_AI_USAGE_OUTPUT_TOKENS], + }; + // Drop undefined entries so that `.optional()` cleanly absent fields + // don't show up as `attr*: undefined` literal keys on the parsed object. + for (const k of Object.keys(candidate)) { + if (candidate[k] === undefined) delete candidate[k]; + } + return candidate; +} + +// --------------------------------------------------------------------------- +// Per-operation tightenings (override specific PromotedAttrsMixin fields). +// --------------------------------------------------------------------------- + +const InvokeAgentTightening = z.object({ + attrConversationId: z.string({ + error: `'${GEN_AI_CONVERSATION_ID}' is required on '${ + OPERATION_INVOKE_AGENT}' spans`, + }), +}); + +const GenerateContentTightening = z.object({ + attrEventId: z.string({ + error: `'${GCP_VERTEX_AGENT_EVENT_ID}' is required on '${ + OPERATION_GENERATE_CONTENT}' spans`, + }), + attrInvocationId: z.string({ + error: `'${GCP_VERTEX_AGENT_INVOCATION_ID}' is required on '${ + OPERATION_GENERATE_CONTENT}' spans`, + }), +}); + +// --------------------------------------------------------------------------- +// Inferred types — promoted attributes union and per-branch span types. +// --------------------------------------------------------------------------- + +type PromotedAttrs = z.infer; + +/** + * Fields originating from the OpenTelemetry span envelope itself. + * + * Note on attribute access: + * - For all non-display logic (filtering, branching, computing derived + * state, etc.), every attribute should be read through a typed `attr*` + * promoted field. This guarantees a single, well-typed access path. + * - The unmodified raw attributes are preserved on + * `rawAttributesUseThisFieldOnlyForDisplay` for display only. + * - The unmodified raw span (exactly as it came over the wire) is + * preserved on `rawSpanUseThisFieldOnlyForDisplay` for display only. + */ +interface SpanEnvelope { name: string; start_time: number; end_time: number; - span_id: string; - parent_span_id?: string; - trace_id: string; - attributes?: any; - children?: Span[]; - invoc_id?: string; - // For backward compatibility. - 'gcp.vertex.agent.llm_request'?: string; - 'gcp.vertex.agent.llm_response'?: string; - - // Logs recorded inside this Span. - logs?: Log[]; + trace_id: string | number; + span_id: string | number; + parent_span_id?: string | number | null; + /** + * Unmodified copy of the raw OpenTelemetry attributes. + * + * **DISPLAY ONLY.** Do not branch or compute on this field — use the + * typed `attr*` promoted fields instead. + */ + rawAttributesUseThisFieldOnlyForDisplay: + Record; + /** + * Unmodified copy of the entire raw OpenTelemetry span as it came over + * the wire (including `attributes`, `logs`, and any other fields). + * + * **DISPLAY ONLY.** Do not branch or compute on this field — use the + * typed envelope fields, `attr*` promoted fields, or `io` instead. + * Surfaced so the "Raw JSON" view can show the unmodified payload + * familiar to anyone reading the upstream OTel export, without leaking + * the post-validation duplicated/typed projection back into the UI. + */ + rawSpanUseThisFieldOnlyForDisplay: unknown; } -export declare interface EventTelemetry { - 'gcp.vertex.agent.llm_request'?: string; - 'gcp.vertex.agent.llm_response'?: string; +/** + * Span emitted with `gen_ai.operation.name == "invoke_agent"`. + * + * `attrConversationId` is guaranteed to be present (string, not optional). + * + * `io` is declared as the literal `undefined` here — `invoke_agent` spans + * never carry LLM IO directly. Declaring it (as undefined) on this branch + * keeps `span.io` accessible across the union without per-branch + * narrowing. + */ +export type InvokeAgentSpan = + SpanEnvelope & PromotedAttrs & { + attrOperationName: typeof OPERATION_INVOKE_AGENT; + attrConversationId: string; + io?: undefined; + }; - // Logs captured in a `generate_content` or `execute_tool` span with `gcp.vertex.agent.event_id` attribute equal to this event. - logs?: Log[]; -} +/** + * Span emitted with `gen_ai.operation.name == "generate_content"`. + * + * `attrEventId` and `attrInvocationId` are guaranteed to be present. + * + * `io` is the Stage-3 coerced input/output projection — populated when + * any of the three IO sources (experimental log attrs, stable child + * logs, or legacy llm_request/llm_response strings) carry data. Child + * log records are NOT exposed on the validated span; their only + * meaningful payload (the LLM IO) is surfaced through `io`. + */ +export type GenerateContentSpan = + SpanEnvelope & PromotedAttrs & { + attrOperationName: typeof OPERATION_GENERATE_CONTENT; + attrEventId: string; + attrInvocationId: string; + io?: SpanIo; + }; + +/** + * Fallback span shape used when `gen_ai.operation.name` is missing or holds + * an unrecognized value (e.g. legacy `call_llm`, `execute_tool`, + * `compact_events`, `invoke_workflow`, `invoke_node`). + * + * `attrOperationName` is the literal `undefined` so that `if + * (span.attrOperationName === 'generate_content')` reliably narrows the + * union to {@link GenerateContentSpan}. Promoted attributes are still + * surfaced (all optional). + * + * `io` is the Stage-3 coerced input/output projection — populated for + * legacy `call_llm` spans that carry `gcp.vertex.agent.llm_*` strings, + * and for any other fallback span that nonetheless emitted GenAI logs. + * Child log records are NOT exposed on the validated span. + */ +export type FallbackSpan = + SpanEnvelope & PromotedAttrs & { + attrOperationName?: undefined; + io?: SpanIo; + }; + +export type ValidatedSpan = + | InvokeAgentSpan + | GenerateContentSpan + | FallbackSpan; + +// --------------------------------------------------------------------------- +// Stage 2: composed SpanValidator. +// --------------------------------------------------------------------------- -export declare interface SpanNode extends Span { - children: SpanNode[]; - depth: number; - duration: number; - id: string; // Using span_id as string ID +/** + * Forwards Zod issues from a Stage-2 sub-parse onto the parent context. + * The Zod v4 `addIssue` API is typed against the raw (input) issue shape + * rather than the parsed one, so we cast through `any`. + */ +function forwardIssues( + ctx: { addIssue(arg: any): void }, + issues: readonly z.core.$ZodIssue[], +): void { + for (const issue of issues) { + ctx.addIssue(issue as any); + } } -export declare interface TimeTick { - position: number; - label: string; +/** + * Applies `PromotedAttrsMixin` (mandatory) plus an optional per-operation + * tightening schema to a raw span's attributes bag, returning the parsed + * promoted fields or `null` if validation failed (issues are forwarded onto + * `ctx`). + */ +function parsePromotedAttrs>( + raw: RawSpan, + tightening: T | null, + ctx: { addIssue(arg: any): void }, +): (PromotedAttrs & z.infer) | null { + const candidate = buildPromotedAttrsCandidate(raw); + + // Run the mixin first to validate the shape of any present attribute. + const mixinResult = PromotedAttrsMixin.safeParse(candidate); + if (!mixinResult.success) { + forwardIssues(ctx, mixinResult.error.issues); + return null; + } + + if (tightening === null) { + return mixinResult.data as PromotedAttrs & z.infer; + } + + // Tightening uses the *original* candidate so that "missing required" + // errors still surface (the mixin made everything optional). + const tighteningResult = tightening.safeParse(candidate); + if (!tighteningResult.success) { + forwardIssues(ctx, tighteningResult.error.issues); + return null; + } + + return { + ...mixinResult.data, + ...tighteningResult.data, + } as PromotedAttrs & z.infer; } + +/** + * Public span validator. Returns a discriminated union narrowable on the + * literal value of `attrOperationName`. + * + * The unmodified input is captured up-front and re-attached on the + * validated result as `rawSpanUseThisFieldOnlyForDisplay` so that + * "Raw JSON" UI views can render the unmodified over-the-wire OTel + * payload (rather than the post-validation typed projection, which + * duplicates information and is unfamiliar to anyone outside ADK Web). + */ +export const SpanValidator: z.ZodType = + z.unknown().transform((rawInput, ctx): ValidatedSpan => { + const parseResult = RawSpanValidator.safeParse(rawInput); + if (!parseResult.success) { + forwardIssues(ctx, parseResult.error.issues); + return z.NEVER as never; + } + const raw = parseResult.data; + const opName = raw.attributes?.[GEN_AI_OPERATION_NAME]; + + // Strip Stage-1's `logs` and `attributes` from the envelope so we + // control which branches surface them. The raw `attributes` bag is + // re-attached under `rawAttributesUseThisFieldOnlyForDisplay` + // below. + const { logs, attributes: rawAttrs, ...envelope } = raw; + + const displayOnlyRawAttrs: + Pick = + rawAttrs !== undefined + ? { rawAttributesUseThisFieldOnlyForDisplay: rawAttrs } + : { rawAttributesUseThisFieldOnlyForDisplay: {} }; + + // Snapshot the unmodified input so the "Raw JSON" view can display + // the over-the-wire payload exactly as it arrived. + const displayOnlyRawSpan: + Pick = { + rawSpanUseThisFieldOnlyForDisplay: rawInput, + }; + + if (opName === OPERATION_INVOKE_AGENT) { + const promoted = parsePromotedAttrs(raw, InvokeAgentTightening, ctx); + if (promoted === null) return z.NEVER as never; + // Logs intentionally dropped — invoke_agent spans don't carry them. + const result: InvokeAgentSpan = { + ...envelope, + ...displayOnlyRawAttrs, + ...displayOnlyRawSpan, + ...promoted, + attrOperationName: OPERATION_INVOKE_AGENT, + }; + return result; + } + + if (opName === OPERATION_GENERATE_CONTENT) { + const promoted = + parsePromotedAttrs(raw, GenerateContentTightening, ctx); + if (promoted === null) return z.NEVER as never; + const io = coerceSpanIo({ attributes: raw.attributes, logs }); + const result: GenerateContentSpan = { + ...envelope, + ...displayOnlyRawAttrs, + ...displayOnlyRawSpan, + ...promoted, + attrOperationName: OPERATION_GENERATE_CONTENT, + ...(io !== undefined ? { io } : {}), + }; + return result; + } + + // Fallback branch — operation name missing or unrecognized. We do + // *not* promote `attrOperationName` here so that the discriminator + // value remains exactly `undefined`, enabling literal narrowing on + // the other branches. + const promoted = parsePromotedAttrs(raw, null, ctx); + if (promoted === null) return z.NEVER as never; + const io = coerceSpanIo({ attributes: raw.attributes, logs }); + const result: FallbackSpan = { + ...envelope, + ...displayOnlyRawAttrs, + ...displayOnlyRawSpan, + ...promoted, + ...(io !== undefined ? { io } : {}), + }; + return result; + }) as z.ZodType; + +// --------------------------------------------------------------------------- +// UI-facing Span type — adds a `children` tree-structure helper on top of +// the validated discriminated union. +// --------------------------------------------------------------------------- + +export type Span = ValidatedSpan & { + children?: Span[]; +}; diff --git a/src/app/core/models/trace.spec.ts b/src/app/core/models/trace.spec.ts new file mode 100644 index 00000000..8f98e45c --- /dev/null +++ b/src/app/core/models/trace.spec.ts @@ -0,0 +1,608 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + FallbackSpan, + GenerateContentSpan, + InvokeAgentSpan, + OPERATION_GENERATE_CONTENT, + OPERATION_INVOKE_AGENT, + SpanValidator, + ValidatedSpan, +} from './Trace'; + +// --------------------------------------------------------------------------- +// Compile-time regression checks. +// +// These never run, but if `SpanValidator.safeParse(...).data` ever stops +// inferring as a 3-branch union, `tsc` (and therefore `npm run build`) will +// fail at the assignments below. +// --------------------------------------------------------------------------- + +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Assert = T; + +// Narrow the SpanValidator output type — what callers actually see. +type InferredSpan = Extract< + ReturnType, + { success: true } +>['data']; + +// 1) The inferred type must be exactly the 3-branch ValidatedSpan union. +type _SpanInferenceCheck = Assert>; + +// 2) Each branch must independently survive — i.e. be assignable from a +// distinct member of the union (not merged into a supertype). +const _invokeAgentBranch: InvokeAgentSpan = {} as Extract< + InferredSpan, + { attrOperationName: typeof OPERATION_INVOKE_AGENT } +>; +const _generateContentBranch: GenerateContentSpan = {} as Extract< + InferredSpan, + { attrOperationName: typeof OPERATION_GENERATE_CONTENT } +>; +const _fallbackBranch: FallbackSpan = {} as Extract< + InferredSpan, + { attrOperationName?: undefined } +>; + +// 3) After narrowing, the per-branch tightenings must be required (not +// optional). If we ever regress to optional we'd be able to assign +// `string | undefined` to these variables. +function _narrowingChecks(span: ValidatedSpan): void { + if (span.attrOperationName === OPERATION_INVOKE_AGENT) { + const _conv: string = span.attrConversationId; // required + // `logs` is dropped from every branch — Stage 2 validates them then + // discards them; the only meaningful payload (LLM IO) is surfaced + // through `io` on the IO-bearing branches. + // @ts-expect-error logs are not exposed on validated spans. + const _logs = span.logs; + // `io` is declared as the literal `undefined` on the InvokeAgent + // branch. Reading it is fine; assigning anything else would fail. + const _ioMustBeUndefined: undefined = span.io; + } else if (span.attrOperationName === OPERATION_GENERATE_CONTENT) { + const _eid: string = span.attrEventId; // required + const _inv: string = span.attrInvocationId; // required + // @ts-expect-error logs are not exposed on validated spans. + const _logs = span.logs; + // `io` is the unified Stage-3 LLM IO projection (optional). + const _io: typeof span.io = span.io; + } else { + // Fallback branch — discriminator is the literal `undefined`. + const _op: undefined = span.attrOperationName; + // Promoted attributes are still surfaced (all optional). + const _eid: string | undefined = span.attrEventId; + const _conv: string | undefined = span.attrConversationId; + // @ts-expect-error logs are not exposed on validated spans. + const _logs = span.logs; + const _io: typeof span.io = span.io; + } +} +void _narrowingChecks; + +// --------------------------------------------------------------------------- +// Runtime tests. +// --------------------------------------------------------------------------- + +describe('Trace Validation', () => { + function validate(json: any): string | undefined { + const result = SpanValidator.safeParse(json); + if (!result.success) { + return result.error.issues + .map(e => `${e.path.join('.')}: ${e.message}`) + .join(', '); + } + return undefined; + } + + function parse(json: any) { + const result = SpanValidator.safeParse(json); + if (!result.success) { + throw new Error( + result.error.issues + .map(e => `${e.path.join('.')}: ${e.message}`) + .join(', ')); + } + return result.data; + } + + function createBaseSpan(extras: any = {}) { + return { + name: 'test-span', + start_time: 1000, + end_time: 2000, + trace_id: 'trace-1', + span_id: 'span-1', + ...extras, + }; + } + + describe('Stage 1: envelope shape', () => { + it('accepts a minimal envelope (no operation_name)', () => { + expect(validate(createBaseSpan())).toBeUndefined(); + }); + + it('rejects when start_time is missing', () => { + const span = createBaseSpan(); + delete span.start_time; + expect(validate(span)).toBeDefined(); + }); + + it('rejects when span_id is missing', () => { + const span = createBaseSpan(); + delete span.span_id; + expect(validate(span)).toBeDefined(); + }); + }); + + describe('Fallback branch (no/unknown operation_name)', () => { + it('accepts a span with no operation_name', () => { + const parsed = parse(createBaseSpan({ + attributes: { 'gcp.vertex.agent.event_id': 'evt-1' }, + })); + // Discriminator must be exactly `undefined`, not the unknown name. + expect(parsed.attrOperationName).toBeUndefined(); + // Promoted attribute is surfaced (optional on the union). + expect(parsed.attrEventId).toBe('evt-1'); + // The raw `attributes` bag is intentionally dropped after Stage 2. + expect((parsed as any).attributes).toBeUndefined(); + }); + + it('does NOT promote unknown operation names into attrOperationName', + () => { + // Per the design, fallback spans must keep `attrOperationName` + // undefined so that literal narrowing on the other branches works. + const parsed = parse(createBaseSpan({ + attributes: { 'gen_ai.operation.name': 'execute_tool' }, + })); + expect(parsed.attrOperationName).toBeUndefined(); + // The original value is no longer reachable — there is no + // `attributes` escape hatch on the validated span. + expect((parsed as any).attributes).toBeUndefined(); + }); + + it('legacy `call_llm` span (no operation_name) flows through fallback', + () => { + const parsed = parse(createBaseSpan({ + name: 'call_llm', + attributes: { + 'gcp.vertex.agent.event_id': 'evt-1', + 'gcp.vertex.agent.llm_request': '{"req": 1}', + 'gcp.vertex.agent.llm_response': '{"res": 1}', + }, + })); + expect(parsed.attrOperationName).toBeUndefined(); + // Non-IO promoted fields are still available — no need for raw + // `attributes` lookups in callers. + expect(parsed.attrEventId).toBe('evt-1'); + // The legacy llm_{request,response} strings are surfaced via + // the unified Stage-3 `io` projection (kind: 'legacy'), with + // the JSON strings parsed into structured payloads. + expect(parsed.io?.kind).toBe('legacy'); + expect(parsed.io?.inputs).toEqual({ req: 1 }); + expect(parsed.io?.outputs).toEqual({ res: 1 }); + }); + + it('still validates the shape of attributes when present', () => { + // `gen_ai.usage.input_tokens` must be a number even on a fallback + // span, because the promotion mixin runs unconditionally. + const span = createBaseSpan({ + attributes: { 'gen_ai.usage.input_tokens': 'not-a-number' }, + }); + expect(validate(span)).toBeDefined(); + }); + }); + + describe('invoke_agent branch', () => { + function invokeAgentSpan(extras: any = {}) { + return createBaseSpan({ + name: 'invoke_agent root_agent', + attributes: { + 'gen_ai.operation.name': OPERATION_INVOKE_AGENT, + 'gen_ai.conversation.id': 'session-1', + ...(extras.attributes ?? {}), + }, + ...Object.fromEntries( + Object.entries(extras).filter(([k]) => k !== 'attributes')), + }); + } + + it('promotes required attrs into typed fields', () => { + const parsed = parse(invokeAgentSpan({ + attributes: { + 'gcp.vertex.agent.associated_event_ids': ['evt-1', 'evt-2'], + 'gen_ai.agent.name': 'root_agent', + 'gen_ai.agent.description': 'The root agent', + }, + })); + expect(parsed.attrOperationName).toBe(OPERATION_INVOKE_AGENT); + if (parsed.attrOperationName === OPERATION_INVOKE_AGENT) { + expect(parsed.attrConversationId).toBe('session-1'); + expect(parsed.attrAssociatedEventIds).toEqual(['evt-1', 'evt-2']); + expect(parsed.attrAgentName).toBe('root_agent'); + expect(parsed.attrAgentDescription).toBe('The root agent'); + } + }); + + it('rejects when conversation.id is missing', () => { + const span = invokeAgentSpan(); + delete (span.attributes as any)['gen_ai.conversation.id']; + expect(validate(span)).toBeDefined(); + }); + + it('does not require invocation_id on this branch', () => { + // invocation_id is required on `generate_content` spans, not on + // `invoke_agent` spans. + const span = invokeAgentSpan(); + expect(validate(span)).toBeUndefined(); + }); + + it('drops logs from the parsed output (typed away)', () => { + const span = invokeAgentSpan({ + logs: [{ + event_name: 'gen_ai.system.message', + body: { content: 'sys' }, + }], + }); + const parsed = parse(span); + expect((parsed as any).logs).toBeUndefined(); + }); + + it('is lenient about extra unknown attribute keys (they are dropped)', + () => { + // Unknown attributes neither cause validation errors nor leak + // into the parsed output — promoted attrs are the single access + // path. + const span = invokeAgentSpan({ + attributes: { 'some.unknown.key': 'value' }, + }); + const parsed = parse(span); + expect((parsed as any).attributes).toBeUndefined(); + expect((parsed as any)['some.unknown.key']).toBeUndefined(); + }); + }); + + describe('generate_content branch', () => { + function generateContentSpan(extras: any = {}) { + return createBaseSpan({ + name: 'generate_content gemini-2.0', + attributes: { + 'gen_ai.operation.name': OPERATION_GENERATE_CONTENT, + 'gcp.vertex.agent.event_id': 'evt-default', + 'gcp.vertex.agent.invocation_id': 'inv-default', + ...(extras.attributes ?? {}), + }, + ...Object.fromEntries( + Object.entries(extras).filter(([k]) => k !== 'attributes')), + }); + } + + it('rejects when event_id is missing (now required on this branch)', + () => { + const span = generateContentSpan(); + delete (span.attributes as any)['gcp.vertex.agent.event_id']; + expect(validate(span)).toBeDefined(); + }); + + it('rejects when invocation_id is missing (required on this branch)', + () => { + const span = generateContentSpan(); + delete (span.attributes as any)['gcp.vertex.agent.invocation_id']; + expect(validate(span)).toBeDefined(); + }); + + it('accepts a minimal generate_content span (only required attrs)', + () => { + const parsed = parse(generateContentSpan()); + expect(parsed.attrOperationName).toBe(OPERATION_GENERATE_CONTENT); + if (parsed.attrOperationName === OPERATION_GENERATE_CONTENT) { + expect(parsed.attrEventId).toBe('evt-default'); + expect(parsed.attrInvocationId).toBe('inv-default'); + // No IO sources present → no `io` projection. + expect(parsed.io).toBeUndefined(); + } + }); + + it('surfaces legacy llm_request/response via Stage-3 `io`', () => { + const parsed = parse(generateContentSpan({ + attributes: { + 'gcp.vertex.agent.llm_request': '{"req": true}', + 'gcp.vertex.agent.llm_response': '{"res": true}', + }, + })); + if (parsed.attrOperationName === OPERATION_GENERATE_CONTENT) { + expect(parsed.io?.kind).toBe('legacy'); + expect(parsed.io?.inputs).toEqual({ req: true }); + expect(parsed.io?.outputs).toEqual({ res: true }); + } + }); + + it('still validates the shape of experimental attrs even though they ' + + 'are not promoted (Stage-1 LogValidator rejects malformed bodies)', + () => { + // Experimental input/output/system/tool attributes are no longer + // promoted to top-level fields, but they remain validated when + // they appear inside the Stage-1 completion-details log + // attributes — see the next two tests. + const parsed = parse(generateContentSpan({ + attributes: { + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + }, + })); + if (parsed.attrOperationName === OPERATION_GENERATE_CONTENT) { + expect(parsed.attrResponseFinishReasons).toEqual(['stop']); + expect(parsed.attrUsageInputTokens).toBe(10); + expect(parsed.attrUsageOutputTokens).toBe(20); + } + }); + + it('rejects malformed gen_ai.input.messages part shape on the ' + + 'completion-details log', + () => { + // Even though `gen_ai.input.messages` is no longer promoted as a + // top-level attr, Stage 1's CompletionDetailsLogValidator still + // validates its shape when it appears as a log attribute. + const span = generateContentSpan({ + logs: [ + { + event_name: 'gen_ai.client.inference.operation.details', + attributes: { + 'gen_ai.input.messages': [ + { + role: 'user', + parts: [{ type: 'text' /* missing content */ }], + }, + ], + }, + }, + ], + }); + expect(validate(span)).toBeDefined(); + }); + + it('rejects unknown part type discriminator on the completion-details ' + + 'log', + () => { + const span = generateContentSpan({ + logs: [ + { + event_name: 'gen_ai.client.inference.operation.details', + attributes: { + 'gen_ai.input.messages': [ + { + role: 'user', + parts: [{ type: 'mystery', content: 'x' }], + }, + ], + }, + }, + ], + }); + expect(validate(span)).toBeDefined(); + }); + + it('drops logs from the parsed output (validated then dropped)', () => { + // Logs are validated at Stage 1 (so malformed shapes are still + // rejected) but dropped from the validated output — their only + // meaningful payload (the LLM IO) is surfaced via `io`. + const parsed = parse(generateContentSpan({ + logs: [ + { + event_name: 'gen_ai.system.message', + body: { content: 'sys' }, + }, + ], + })); + expect((parsed as any).logs).toBeUndefined(); + }); + + it('coerces stable child logs into Stage-3 `io` (kind: stable)', () => { + const parsed = parse(generateContentSpan({ + logs: [ + { + event_name: 'gen_ai.system.message', + body: { content: 'sys' }, + }, + { + event_name: 'gen_ai.user.message', + body: { role: 'user', content: { parts: [{ text: 'hi' }] } }, + }, + { + event_name: 'gen_ai.user.message', + body: { role: 'user', content: { parts: [{ text: 'again' }] } }, + }, + { + event_name: 'gen_ai.choice', + body: { content: { role: 'model', parts: [{ text: 'ok' }] } }, + }, + ], + })); + if (parsed.attrOperationName === OPERATION_GENERATE_CONTENT) { + expect(parsed.io?.kind).toBe('stable'); + if (parsed.io?.kind === 'stable') { + expect(parsed.io.inputs.system_instruction).toEqual({ + content: 'sys', + }); + expect(parsed.io.inputs.user_messages.length).toBe(2); + expect(parsed.io.outputs).toEqual({ + content: { role: 'model', parts: [{ text: 'ok' }] }, + }); + } + } + }); + + it('coerces the experimental completion-details log into Stage-3 ' + + '`io` (kind: experimental)', + () => { + const parsed = parse(generateContentSpan({ + logs: [ + { + event_name: 'gen_ai.client.inference.operation.details', + attributes: { + 'gen_ai.input.messages': [ + { + role: 'user', + parts: [{ type: 'text', content: 'hi' }], + }, + ], + 'gen_ai.output.messages': [ + { + role: 'assistant', + parts: [{ type: 'text', content: 'reply' }], + finish_reason: 'stop', + }, + ], + 'gen_ai.system_instructions': [ + { type: 'text', content: 'you are a helper' }, + ], + 'gen_ai.tool.definitions': [ + { type: 'function', name: 'fn' }, + ], + 'gen_ai.usage.input_tokens': 7, + 'gen_ai.usage.output_tokens': 11, + }, + }, + ], + })); + if (parsed.attrOperationName === OPERATION_GENERATE_CONTENT) { + expect(parsed.io?.kind).toBe('experimental'); + if (parsed.io?.kind === 'experimental') { + expect((parsed.io.inputs.user_messages as any)?.length).toBe(1); + expect((parsed.io.inputs.tool_definitions as any)?.length) + .toBe(1); + expect((parsed.io.outputs as any)?.length).toBe(1); + } + } + }); + + it('is lenient about extra unknown attribute keys (they are dropped)', + () => { + // Unknown attributes neither cause validation errors nor leak + // into the parsed output. + const parsed = parse(generateContentSpan({ + attributes: { 'some.unknown.key': 'value' }, + })); + expect((parsed as any).attributes).toBeUndefined(); + expect((parsed as any)['some.unknown.key']).toBeUndefined(); + }); + }); + + describe('legacy log-validation tests (preserved from prior version)', () => { + function genCSpanWithLogs(logs: any[]) { + return createBaseSpan({ + name: 'generate_content x', + attributes: { + 'gen_ai.operation.name': OPERATION_GENERATE_CONTENT, + 'gcp.vertex.agent.event_id': 'evt-1', + 'gcp.vertex.agent.invocation_id': 'inv-1', + }, + logs, + }); + } + + it('valid system message log', () => { + expect(validate(genCSpanWithLogs([ + { event_name: 'gen_ai.system.message', body: { content: 'sys' } }, + ]))).toBeUndefined(); + }); + + it('valid user message log with text', () => { + expect(validate(genCSpanWithLogs([{ + event_name: 'gen_ai.user.message', + body: { role: 'user', content: { parts: [{ text: 'hi' }] } }, + }]))).toBeUndefined(); + }); + + it('valid choice with function_call', () => { + expect(validate(genCSpanWithLogs([{ + event_name: 'gen_ai.choice', + body: { + content: { + role: 'model', + parts: [{ + function_call: { + name: 'get_weather', + args: { location: 'London' }, + }, + }], + }, + }, + }]))).toBeUndefined(); + }); + + it('user message with function_response', () => { + expect(validate(genCSpanWithLogs([{ + event_name: 'gen_ai.user.message', + body: { + role: 'user', + content: { + parts: [{ + function_response: { + name: 'get_weather', + response: { weather: 'sunny' }, + }, + }], + }, + }, + }]))).toBeUndefined(); + }); + + it('user message body as a JSON string', () => { + expect(validate(genCSpanWithLogs([{ + event_name: 'gen_ai.user.message', + body: JSON.stringify({ + role: 'user', + content: { parts: [{ text: 'Hello from JSON string' }] }, + }), + }]))).toBeUndefined(); + }); + + it('rejects system message missing content', () => { + expect(validate(genCSpanWithLogs([ + { event_name: 'gen_ai.system.message', body: {} }, + ]))).toBeDefined(); + }); + + it('rejects function_call missing name', () => { + expect(validate(genCSpanWithLogs([{ + event_name: 'gen_ai.user.message', + body: { + role: 'user', + content: { + parts: [{ function_call: { args: { location: 'London' } } }], + }, + }, + }]))).toBeDefined(); + }); + + it('rejects choice with no role', () => { + expect(validate(genCSpanWithLogs([{ + event_name: 'gen_ai.choice', + body: { content: { parts: [{ text: 'no role' }] } }, + }]))).toBeDefined(); + }); + + it('rejects invalid JSON-string body', () => { + expect(validate(genCSpanWithLogs([{ + event_name: 'gen_ai.user.message', + body: '{ invalid json }', + }]))).toBeDefined(); + }); + }); +}); diff --git a/src/app/core/models/trace/ExperimentalSemconv.spec.ts b/src/app/core/models/trace/ExperimentalSemconv.spec.ts new file mode 100644 index 00000000..8aa40e1f --- /dev/null +++ b/src/app/core/models/trace/ExperimentalSemconv.spec.ts @@ -0,0 +1,331 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CompletionDetailsLogValidator, + GEN_AI_COMPLETION_DETAILS_EVENT, + InputMessageValidator, + InputMessagesAttrValidator, + OutputMessageValidator, + OutputMessagesAttrValidator, + PartValidator, + SystemInstructionsAttrValidator, + ToolDefinitionValidator, + ToolDefinitionsAttrValidator, +} from './ExperimentalSemconv'; + +describe('ExperimentalSemconv', () => { + describe('PartValidator (discriminated on `type`)', () => { + it('accepts a text part', () => { + const result = PartValidator.safeParse({ + type: 'text', + content: 'hello', + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a blob part with arbitrary `data`', () => { + const result = PartValidator.safeParse({ + type: 'blob', + mime_type: 'image/png', + data: 'base64stuff', + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a file_data part', () => { + const result = PartValidator.safeParse({ + type: 'file_data', + mime_type: 'application/pdf', + uri: 'gs://bucket/file.pdf', + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a tool_call part with arguments', () => { + const result = PartValidator.safeParse({ + type: 'tool_call', + id: 'call_123', + name: 'get_weather', + arguments: { location: 'London' }, + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a tool_call part with null id and arguments', () => { + const result = PartValidator.safeParse({ + type: 'tool_call', + id: null, + name: 'get_weather', + arguments: null, + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a tool_call_response part', () => { + const result = PartValidator.safeParse({ + type: 'tool_call_response', + id: 'call_123', + response: { weather: 'sunny' }, + }); + expect(result.success).toBeTrue(); + }); + + it('rejects a part with an unknown discriminator value', () => { + const result = PartValidator.safeParse({ + type: 'something_else', + content: 'hi', + }); + expect(result.success).toBeFalse(); + }); + + it('rejects a text part missing content', () => { + const result = PartValidator.safeParse({ type: 'text' }); + expect(result.success).toBeFalse(); + }); + + it('rejects a tool_call missing name', () => { + const result = PartValidator.safeParse({ + type: 'tool_call', + id: 'x', + arguments: {}, + }); + expect(result.success).toBeFalse(); + }); + + it('is lenient about extra unknown keys', () => { + const result = PartValidator.safeParse({ + type: 'text', + content: 'hi', + unknownExtraField: 42, + }); + expect(result.success).toBeTrue(); + }); + }); + + describe('InputMessageValidator', () => { + it('accepts a message with mixed parts', () => { + const result = InputMessageValidator.safeParse({ + role: 'user', + parts: [ + { type: 'text', content: 'hi' }, + { + type: 'tool_call', + id: 'call_1', + name: 'fn', + arguments: { a: 1 }, + }, + ], + }); + expect(result.success).toBeTrue(); + }); + + it('rejects a message missing role', () => { + const result = InputMessageValidator.safeParse({ + parts: [{ type: 'text', content: 'hi' }], + }); + expect(result.success).toBeFalse(); + }); + + it('rejects a message with an invalid part shape', () => { + const result = InputMessageValidator.safeParse({ + role: 'user', + parts: [{ type: 'text' /* missing content */ }], + }); + expect(result.success).toBeFalse(); + }); + }); + + describe('OutputMessageValidator', () => { + it('accepts a message with finish_reason', () => { + const result = OutputMessageValidator.safeParse({ + role: 'assistant', + parts: [{ type: 'text', content: 'done' }], + finish_reason: 'stop', + }); + expect(result.success).toBeTrue(); + }); + + it('rejects a message missing finish_reason', () => { + const result = OutputMessageValidator.safeParse({ + role: 'assistant', + parts: [{ type: 'text', content: 'done' }], + }); + expect(result.success).toBeFalse(); + }); + }); + + describe('ToolDefinitionValidator', () => { + it('accepts a function tool definition', () => { + const result = ToolDefinitionValidator.safeParse({ + type: 'function', + name: 'get_weather', + description: 'Returns the weather', + parameters: { type: 'object', properties: {} }, + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a function tool definition with null description/parameters', + () => { + const result = ToolDefinitionValidator.safeParse({ + type: 'function', + name: 'fn', + description: null, + parameters: null, + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a generic tool definition', () => { + const result = ToolDefinitionValidator.safeParse({ + name: 'google_search', + type: 'google_search', + }); + expect(result.success).toBeTrue(); + }); + + it('rejects a definition missing name', () => { + const result = ToolDefinitionValidator.safeParse({ type: 'function' }); + expect(result.success).toBeFalse(); + }); + }); + + describe('attribute-level validators (accept JSON string or array)', () => { + const inputs = [{ role: 'user', parts: [{ type: 'text', content: 'hi' }] }]; + + it('InputMessagesAttrValidator accepts the parsed array form', () => { + const result = InputMessagesAttrValidator.safeParse(inputs); + expect(result.success).toBeTrue(); + }); + + it('InputMessagesAttrValidator accepts the JSON-string form', () => { + const result = + InputMessagesAttrValidator.safeParse(JSON.stringify(inputs)); + expect(result.success).toBeTrue(); + }); + + it('InputMessagesAttrValidator rejects malformed JSON-string', () => { + const result = InputMessagesAttrValidator.safeParse('{ broken'); + expect(result.success).toBeFalse(); + }); + + it('OutputMessagesAttrValidator accepts a valid array', () => { + const result = OutputMessagesAttrValidator.safeParse([ + { + role: 'assistant', + parts: [{ type: 'text', content: 'ok' }], + finish_reason: 'stop', + }, + ]); + expect(result.success).toBeTrue(); + }); + + it('SystemInstructionsAttrValidator accepts a valid Part array', () => { + const result = SystemInstructionsAttrValidator.safeParse([ + { type: 'text', content: 'You are helpful.' }, + ]); + expect(result.success).toBeTrue(); + }); + + it('ToolDefinitionsAttrValidator accepts mixed function/generic tools', + () => { + const result = ToolDefinitionsAttrValidator.safeParse([ + { + type: 'function', + name: 'fn', + description: 'd', + parameters: { type: 'object' }, + }, + { name: 'google_search', type: 'google_search' }, + ]); + expect(result.success).toBeTrue(); + }); + }); + + describe('CompletionDetailsLogValidator', () => { + it('accepts the completion-details event with full payload attributes', + () => { + const result = CompletionDetailsLogValidator.safeParse({ + event_name: GEN_AI_COMPLETION_DETAILS_EVENT, + attributes: { + 'gen_ai.input.messages': [ + { role: 'user', parts: [{ type: 'text', content: 'hi' }] }, + ], + 'gen_ai.output.messages': [ + { + role: 'assistant', + parts: [{ type: 'text', content: 'reply' }], + finish_reason: 'stop', + }, + ], + 'gen_ai.system_instructions': [ + { type: 'text', content: 'sys' }, + ], + 'gen_ai.tool.definitions': [], + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 7, + 'gen_ai.usage.output_tokens': 11, + }, + }); + expect(result.success).toBeTrue(); + }); + + it('accepts attributes encoded as JSON strings', () => { + // Mirrors the wire format produced when the experimental exporter + // serializes via `_safe_json_serialize_no_whitespaces` (see + // ``_experimental_semconv.py``). + const result = CompletionDetailsLogValidator.safeParse({ + event_name: GEN_AI_COMPLETION_DETAILS_EVENT, + attributes: { + 'gen_ai.input.messages': + '[{"role":"user","parts":[{"type":"text","content":"hi"}]}]', + }, + }); + expect(result.success).toBeTrue(); + }); + + it('passes through unknown common attributes (e.g. event_id)', () => { + const result = CompletionDetailsLogValidator.safeParse({ + event_name: GEN_AI_COMPLETION_DETAILS_EVENT, + attributes: { + 'gen_ai.agent.name': 'root_agent', + 'gen_ai.conversation.id': 'sess-1', + 'gcp.vertex.agent.event_id': 'evt-1', + 'gcp.vertex.agent.invocation_id': 'inv-1', + 'user.id': 'someone', + }, + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a record with no attributes (initial export, response not set yet)', + () => { + const result = CompletionDetailsLogValidator.safeParse({ + event_name: GEN_AI_COMPLETION_DETAILS_EVENT, + }); + expect(result.success).toBeTrue(); + }); + + it('rejects a record with an unrelated event_name', () => { + const result = CompletionDetailsLogValidator.safeParse({ + event_name: 'some.other.event', + }); + expect(result.success).toBeFalse(); + }); + }); +}); diff --git a/src/app/core/models/trace/ExperimentalSemconv.ts b/src/app/core/models/trace/ExperimentalSemconv.ts new file mode 100644 index 00000000..8507f660 --- /dev/null +++ b/src/app/core/models/trace/ExperimentalSemconv.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Validators and types for the *experimental* OpenTelemetry GenAI semantic + * conventions — i.e. the structured span attributes emitted by the Python ADK + * from `google.adk.telemetry._experimental_semconv` (`gen_ai.input.messages`, + * `gen_ai.output.messages`, `gen_ai.system_instructions`, + * `gen_ai.tool.definitions`, etc.). + * + * This module is private to `src/app/core/models/trace/`. Public symbols are + * re-exported from `Trace.ts`. + */ + +import { z } from 'zod'; + +import { jsonStringOr } from './Shared'; + +// --------------------------------------------------------------------------- +// Attribute name constants — mirror the OTel incubating GenAI attributes used +// by google.adk.telemetry._experimental_semconv. +// --------------------------------------------------------------------------- + +export const GEN_AI_INPUT_MESSAGES = 'gen_ai.input.messages'; +export const GEN_AI_OUTPUT_MESSAGES = 'gen_ai.output.messages'; +export const GEN_AI_SYSTEM_INSTRUCTIONS = 'gen_ai.system_instructions'; +export const GEN_AI_TOOL_DEFINITIONS = 'gen_ai.tool.definitions'; +export const GEN_AI_RESPONSE_FINISH_REASONS = + 'gen_ai.response.finish_reasons'; +export const GEN_AI_USAGE_INPUT_TOKENS = 'gen_ai.usage.input_tokens'; +export const GEN_AI_USAGE_OUTPUT_TOKENS = 'gen_ai.usage.output_tokens'; + +export const FUNCTION_TOOL_DEFINITION_TYPE = 'function'; + +// --------------------------------------------------------------------------- +// Log event names — mirror constants in +// google.adk.telemetry._experimental_semconv. +// --------------------------------------------------------------------------- + +export const GEN_AI_COMPLETION_DETAILS_EVENT = + 'gen_ai.client.inference.operation.details'; + +// --------------------------------------------------------------------------- +// Part validators (mirror Section A TypedDicts in +// google.adk.telemetry._experimental_semconv). +// --------------------------------------------------------------------------- + +const TextPartValidator = z.object({ + type: z.literal('text'), + content: z.string(), +}); + +const BlobPartValidator = z.object({ + type: z.literal('blob'), + mime_type: z.string(), + // Python types this as `bytes`. After JSON serialization it could be a + // base64 string, an array of integers, or null. Stay lenient. + data: z.any(), +}); + +const FileDataPartValidator = z.object({ + type: z.literal('file_data'), + mime_type: z.string(), + uri: z.string(), +}); + +const ToolCallPartValidator = z.object({ + type: z.literal('tool_call'), + id: z.string().nullable().optional(), + name: z.string(), + arguments: z.record(z.string(), z.any()).nullable().optional(), +}); + +const ToolCallResponsePartValidator = z.object({ + type: z.literal('tool_call_response'), + id: z.string().nullable().optional(), + response: z.record(z.string(), z.any()).nullable().optional(), +}); + +/** + * Discriminated union of the experimental Part shapes. Validation routes on + * the `type` literal. + */ +export const PartValidator = z.discriminatedUnion('type', [ + TextPartValidator, + BlobPartValidator, + FileDataPartValidator, + ToolCallPartValidator, + ToolCallResponsePartValidator, +]); + +// --------------------------------------------------------------------------- +// Message validators +// --------------------------------------------------------------------------- + +export const InputMessageValidator = z.object({ + role: z.string(), + parts: z.array(PartValidator), +}); + +export const OutputMessageValidator = z.object({ + role: z.string(), + parts: z.array(PartValidator), + finish_reason: z.string(), +}); + +// --------------------------------------------------------------------------- +// Tool definition validators +// --------------------------------------------------------------------------- + +const FunctionToolDefinitionValidator = z.object({ + type: z.literal(FUNCTION_TOOL_DEFINITION_TYPE), + name: z.string(), + description: z.string().nullable().optional(), + parameters: z.record(z.string(), z.any()).nullable().optional(), +}); + +const GenericToolDefinitionValidator = z.object({ + name: z.string(), + type: z.string(), +}); + +/** + * Tool definition: either a `FunctionToolDefinition` (when `type === 'function'`) + * or a generic shape. Zod tries variants in order, so the function shape is + * preferred when applicable. + */ +export const ToolDefinitionValidator = z.union([ + FunctionToolDefinitionValidator, + GenericToolDefinitionValidator, +]); + +// --------------------------------------------------------------------------- +// Span-attribute validators (used by Trace.ts to validate the values found +// in `span.attributes`). Each entry accepts either the parsed array form OR a +// JSON string, because Python ADK serializes these via +// `_safe_json_serialize_no_whitespaces` (see +// `_experimental_semconv.py:_build_completion_span_attributes`). +// --------------------------------------------------------------------------- + +export const InputMessagesAttrValidator = + jsonStringOr(z.array(InputMessageValidator)); + +export const OutputMessagesAttrValidator = + jsonStringOr(z.array(OutputMessageValidator)); + +export const SystemInstructionsAttrValidator = + jsonStringOr(z.array(PartValidator)); + +export const ToolDefinitionsAttrValidator = + jsonStringOr(z.array(ToolDefinitionValidator)); + +export const ResponseFinishReasonsAttrValidator = z.array(z.string()); + +export const UsageTokensAttrValidator = z.number(); + +// --------------------------------------------------------------------------- +// Completion-details log event (experimental semconv). +// +// Unlike the *stable* GenAI log events (system/user/choice), the experimental +// completion-details event carries its payload on the log record's +// `attributes` rather than its `body`. Each attribute mirrors the +// corresponding span attribute and is validated using the same per-attribute +// validators above. +// +// All payload attributes are optional — the Python ADK omits attributes +// whose source data wasn't available (e.g. `gen_ai.tool.definitions` is +// absent when the request had no tools). +// --------------------------------------------------------------------------- + +export const CompletionDetailsLogAttributesValidator = z.object({ + [GEN_AI_INPUT_MESSAGES]: InputMessagesAttrValidator.optional(), + [GEN_AI_OUTPUT_MESSAGES]: OutputMessagesAttrValidator.optional(), + [GEN_AI_SYSTEM_INSTRUCTIONS]: SystemInstructionsAttrValidator.optional(), + [GEN_AI_TOOL_DEFINITIONS]: ToolDefinitionsAttrValidator.optional(), + [GEN_AI_RESPONSE_FINISH_REASONS]: + ResponseFinishReasonsAttrValidator.optional(), + [GEN_AI_USAGE_INPUT_TOKENS]: UsageTokensAttrValidator.optional(), + [GEN_AI_USAGE_OUTPUT_TOKENS]: UsageTokensAttrValidator.optional(), +}).loose(); // Unknown common attributes (e.g. `gen_ai.agent.name`, + // `gcp.vertex.agent.event_id`) are allowed through unchanged. + +export const CompletionDetailsLogValidator = z.object({ + event_name: z.literal(GEN_AI_COMPLETION_DETAILS_EVENT), + // Python emits this log with no body — only attributes. + body: z.unknown().optional(), + attributes: CompletionDetailsLogAttributesValidator.optional(), +}); + +// --------------------------------------------------------------------------- +// Inferred types +// --------------------------------------------------------------------------- + +export type Part = z.infer; +export type InputMessage = z.infer; +export type OutputMessage = z.infer; +export type FunctionToolDefinition = + z.infer; +export type GenericToolDefinition = + z.infer; +export type ToolDefinition = z.infer; +export type CompletionDetailsLog = + z.infer; diff --git a/src/app/core/models/trace/Shared.ts b/src/app/core/models/trace/Shared.ts new file mode 100644 index 00000000..65034d56 --- /dev/null +++ b/src/app/core/models/trace/Shared.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Shared low-level Zod primitives used by the trace validators. + * + * This module is private to `src/app/core/models/trace/`. Public symbols are + * re-exported from `Trace.ts`. + */ + +import { z } from 'zod'; + +// Define the recursive type for OTelAnyValue +const literalSchema = z.union([z.string(), z.number(), z.boolean()]); + +/** + * Recursive schema matching OpenTelemetry `AnyValue` — strings, numbers, + * booleans, arrays of those, and string-keyed maps of those. + */ +export const oTelAnyValueSchema: z.ZodType = z.lazy(() => + z.union([ + literalSchema, + z.array(oTelAnyValueSchema), + z.record(z.string(), oTelAnyValueSchema), + ]), +); + +/** + * Strips entries whose value is `null` from a parsed object. + * + * Used because Python ADK serializes optional fields as `null`, but ADK Web + * prefers the JS-idiomatic "field absent" representation. + */ +export function withStrippedNulls(schema: T) { + return schema.transform((data) => { + if (!data || typeof data !== 'object') { + return data; + } + const cleanData: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (value !== null) { + cleanData[key] = value; + } + } + return cleanData; + }); +} + +/** + * Parses a JSON string and surfaces parse errors as Zod issues. + */ +export const jsonStringSchema = z.string().transform((str, ctx) => { + try { + return JSON.parse(str); + } catch (e) { + ctx.addIssue({ code: 'custom', message: 'Invalid JSON string' }); + return z.NEVER; + } +}); + +/** + * Helper that accepts either an already-parsed value or a JSON string that + * parses to a value matching the given schema. + * + * Python ADK serializes some span attributes via + * `_safe_json_serialize_no_whitespaces(...)` (see + * `_experimental_semconv.py:_build_completion_span_attributes`), so on the + * wire we may see either form. + */ +export const jsonStringOr = (schema: T) => + z.union([schema, jsonStringSchema.pipe(schema)]); diff --git a/src/app/core/models/trace/SpanIo.ts b/src/app/core/models/trace/SpanIo.ts new file mode 100644 index 00000000..f95f1194 --- /dev/null +++ b/src/app/core/models/trace/SpanIo.ts @@ -0,0 +1,293 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Stage-3 input/output coercion. + * + * Stage 1 validates the OTel envelope; Stage 2 promotes well-known + * attributes into typed `attr*` fields. Stage 3 reads those promoted + * fields (and, for experimental semconv, the completion-details log + * attributes) and projects them into a single discriminated `SpanIo` + * shape that consumers can render uniformly regardless of which semantic + * convention produced the underlying span. + * + * Three sources are recognised, attempted in this order on each span: + * + * 1. **Experimental** (`kind: 'experimental'`) — payload lives on the + * `gen_ai.client.inference.operation.details` log's `attributes` + * (`gen_ai.input.messages`, `gen_ai.system_instructions`, + * `gen_ai.tool.definitions`, `gen_ai.output.messages`). + * + * 2. **Stable** (`kind: 'stable'`) — payload lives on the span's child + * log records: `gen_ai.system.message` (single body), one or more + * `gen_ai.user.message` (bodies), and `gen_ai.choice` (body) for the + * output. + * + * 3. **Legacy** (`kind: 'legacy'`) — payload lives in the + * `gcp.vertex.agent.llm_{request,response}` JSON-string attributes. + * The strings are parsed; if either fails to parse it is left as a + * raw string so the caller can still surface something useful. + * + * If none of the three sources have any data, the result is `undefined`. + * + * The shapes here are intentionally **opaque** (mostly `unknown`) — these + * are passed through to the UI for structural display, so we do not need + * to re-validate the inner content beyond what Stage 2 has already done. + * + * This module is private to `src/app/core/models/trace/`. Public symbols + * are re-exported from `Trace.ts`. + */ + +import { + GEN_AI_COMPLETION_DETAILS_EVENT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, +} from './ExperimentalSemconv'; +import { + GEN_AI_CHOICE_EVENT, + GEN_AI_SYSTEM_MESSAGE_EVENT, + GEN_AI_USER_MESSAGE_EVENT, +} from './StableSemconv'; + +// Wire keys for the legacy `call_llm` attributes. Defined locally here +// (rather than imported from `Trace.ts`) to avoid a circular import. +const GCP_VERTEX_AGENT_LLM_REQUEST = 'gcp.vertex.agent.llm_request'; +const GCP_VERTEX_AGENT_LLM_RESPONSE = 'gcp.vertex.agent.llm_response'; + +// --------------------------------------------------------------------------- +// IO variant types. +// +// Each variant carries a `kind` discriminator. Field shapes are deliberately +// opaque (`unknown`) because consumers only render them as structured trees; +// validation already happened in Stage 2. +// --------------------------------------------------------------------------- + +/** + * Inputs/outputs derived from the legacy `gcp.vertex.agent.llm_*` string + * attributes. The strings are JSON-parsed when possible; on parse failure + * the raw string is preserved verbatim so the UI can still display + * *something*. + */ +export interface LegacySpanIo { + kind: 'legacy'; + inputs: unknown; + outputs: unknown; +} + +/** + * Inputs/outputs derived from stable-semconv child logs. + * + * - `system_instruction`: the body of the (single) `gen_ai.system.message` + * log, or `undefined` if no system message was emitted. + * - `user_messages`: bodies of all `gen_ai.user.message` logs, in + * emission order. Empty array if none were emitted. + * - `output`: the body of the (single) `gen_ai.choice` log, or + * `undefined` if no choice was emitted yet. + */ +export interface StableSpanIo { + kind: 'stable'; + inputs: { + system_instruction: unknown; + user_messages: unknown[]; + }; + outputs: unknown; +} + +/** + * Inputs/outputs derived from an experimental-semconv + * `gen_ai.client.inference.operation.details` log's attributes. + * + * - `system_instruction`: value of `gen_ai.system_instructions`. + * - `user_messages`: value of `gen_ai.input.messages`. + * - `tool_definitions`: value of `gen_ai.tool.definitions`. + * - `output`: value of `gen_ai.output.messages`. + * + * Any attribute not present on the log is `undefined`. + */ +export interface ExperimentalSpanIo { + kind: 'experimental'; + inputs: { + system_instruction: unknown; + user_messages: unknown; + tool_definitions: unknown; + }; + outputs: unknown; +} + +/** + * Discriminated union of the IO shapes a span may project into. Use the + * `kind` field to discriminate. + */ +export type SpanIo = LegacySpanIo | StableSpanIo | ExperimentalSpanIo; + +// --------------------------------------------------------------------------- +// Coercion entry point. +// --------------------------------------------------------------------------- + +/** + * Inputs to {@link coerceSpanIo}. This is intentionally a structural type + * rather than a reference to `ValidatedSpan` so that this module can stay + * independent of `Trace.ts`'s broader span union (and so it can be unit- + * tested with minimal fixtures). + * + * - `attributes`: the raw OTel attribute bag, wire-keyed. Read for the + * legacy `gcp.vertex.agent.llm_{request,response}` JSON-string + * attributes. + * - `logs`: the span's child log records. Read for stable-semconv + * system/user/choice bodies and for the experimental + * `gen_ai.client.inference.operation.details` log attributes. + */ +export interface CoerceSpanIoInput { + attributes?: Record; + logs?: ReadonlyArray<{ + event_name: string; + body?: unknown; + attributes?: Record; + }>; +} + +/** + * Projects a span's inputs/outputs into the unified {@link SpanIo} shape. + * + * Sources are tried in this order, first match wins: + * 1. Experimental (`gen_ai.client.inference.operation.details` log + * attributes). + * 2. Stable (`gen_ai.system.message` / `gen_ai.user.message` / + * `gen_ai.choice` child logs). + * 3. Legacy (`gcp.vertex.agent.llm_{request,response}` string attrs). + * + * Returns `undefined` if no source produced any data. + */ +export function coerceSpanIo(span: CoerceSpanIoInput): SpanIo | undefined { + const experimental = tryExperimental(span); + if (experimental !== undefined) return experimental; + + const stable = tryStable(span); + if (stable !== undefined) return stable; + + const legacy = tryLegacy(span); + if (legacy !== undefined) return legacy; + + return undefined; +} + +// --------------------------------------------------------------------------- +// Per-source extractors. +// --------------------------------------------------------------------------- + +function tryExperimental( + span: CoerceSpanIoInput, + ): ExperimentalSpanIo | undefined { + const log = (span.logs ?? []).find( + (l) => l.event_name === GEN_AI_COMPLETION_DETAILS_EVENT, + ); + if (log === undefined) return undefined; + + const attrs = log.attributes ?? {}; + const systemInstruction = attrs[GEN_AI_SYSTEM_INSTRUCTIONS]; + const userMessages = attrs[GEN_AI_INPUT_MESSAGES]; + const toolDefinitions = attrs[GEN_AI_TOOL_DEFINITIONS]; + const output = attrs[GEN_AI_OUTPUT_MESSAGES]; + + // If the completion-details log is present but carries none of the + // payload attributes, fall through to other sources rather than emit + // an empty experimental IO. + if (systemInstruction === undefined && userMessages === undefined && + toolDefinitions === undefined && output === undefined) { + return undefined; + } + + return { + kind: 'experimental', + inputs: { + system_instruction: systemInstruction, + user_messages: userMessages, + tool_definitions: toolDefinitions, + }, + outputs: output, + }; +} + +function tryStable(span: CoerceSpanIoInput): StableSpanIo | undefined { + const logs = span.logs ?? []; + + let systemInstruction: unknown = undefined; + const userMessages: unknown[] = []; + let output: unknown = undefined; + + for (const log of logs) { + switch (log.event_name) { + case GEN_AI_SYSTEM_MESSAGE_EVENT: + // If multiple system messages were emitted (not expected, but + // possible), the last one wins — matches "single system + // instruction" semantics. + systemInstruction = log.body; + break; + case GEN_AI_USER_MESSAGE_EVENT: + userMessages.push(log.body); + break; + case GEN_AI_CHOICE_EVENT: + output = log.body; + break; + default: + break; + } + } + + if (systemInstruction === undefined && userMessages.length === 0 && + output === undefined) { + return undefined; + } + + return { + kind: 'stable', + inputs: { + system_instruction: systemInstruction, + user_messages: userMessages, + }, + outputs: output, + }; +} + +function tryLegacy(span: CoerceSpanIoInput): LegacySpanIo | undefined { + const attrs = span.attributes ?? {}; + const req = attrs[GCP_VERTEX_AGENT_LLM_REQUEST]; + const res = attrs[GCP_VERTEX_AGENT_LLM_RESPONSE]; + if (req === undefined && res === undefined) return undefined; + + return { + kind: 'legacy', + inputs: parseJsonOrPassThrough(req), + outputs: parseJsonOrPassThrough(res), + }; +} + +/** + * If the input is a string, attempts to JSON-parse it (returning the raw + * string on parse failure so the UI can still surface its contents). For + * any non-string input, returns the value unchanged. + */ +function parseJsonOrPassThrough(value: unknown): unknown { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} diff --git a/src/app/core/models/trace/StableSemconv.spec.ts b/src/app/core/models/trace/StableSemconv.spec.ts new file mode 100644 index 00000000..e82278c8 --- /dev/null +++ b/src/app/core/models/trace/StableSemconv.spec.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + GEN_AI_CHOICE_EVENT, + GEN_AI_SYSTEM_MESSAGE_EVENT, + GEN_AI_USER_MESSAGE_EVENT, + isChoiceLog, + isSystemMessageLog, + isUserMessageLog, + LogValidator, + PromptResponseLogValidator, + SystemMessageLogValidator, +} from './StableSemconv'; + +describe('StableSemconv', () => { + describe('SystemMessageLogValidator', () => { + it('accepts a valid system message body', () => { + const result = SystemMessageLogValidator.safeParse({ + event_name: GEN_AI_SYSTEM_MESSAGE_EVENT, + body: { content: 'You are a helpful assistant.' }, + }); + expect(result.success).toBeTrue(); + }); + + it('rejects when body.content is missing', () => { + const result = SystemMessageLogValidator.safeParse({ + event_name: GEN_AI_SYSTEM_MESSAGE_EVENT, + body: {}, + }); + expect(result.success).toBeFalse(); + }); + + it('rejects when event_name is wrong', () => { + const result = SystemMessageLogValidator.safeParse({ + event_name: GEN_AI_USER_MESSAGE_EVENT, + body: { content: 'hi' }, + }); + expect(result.success).toBeFalse(); + }); + }); + + describe('PromptResponseLogValidator', () => { + it('accepts a user message with text part', () => { + const result = PromptResponseLogValidator.safeParse({ + event_name: GEN_AI_USER_MESSAGE_EVENT, + body: { + role: 'user', + content: { parts: [{ text: 'Hello' }] }, + }, + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a choice with function_call part', () => { + const result = PromptResponseLogValidator.safeParse({ + event_name: GEN_AI_CHOICE_EVENT, + body: { + content: { + role: 'model', + parts: [{ + function_call: { + name: 'get_weather', + args: { location: 'London' }, + }, + }], + }, + }, + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a function_response part', () => { + const result = PromptResponseLogValidator.safeParse({ + event_name: GEN_AI_USER_MESSAGE_EVENT, + body: { + role: 'user', + content: { + parts: [{ + function_response: { + name: 'get_weather', + response: { weather: 'sunny' }, + }, + }], + }, + }, + }); + expect(result.success).toBeTrue(); + }); + + it('accepts a JSON-string body', () => { + const result = PromptResponseLogValidator.safeParse({ + event_name: GEN_AI_USER_MESSAGE_EVENT, + body: JSON.stringify({ + role: 'user', + content: { parts: [{ text: 'Hello from JSON' }] }, + }), + }); + expect(result.success).toBeTrue(); + }); + + it('rejects an invalid JSON-string body', () => { + const result = PromptResponseLogValidator.safeParse({ + event_name: GEN_AI_USER_MESSAGE_EVENT, + body: '{ this is not valid json }', + }); + expect(result.success).toBeFalse(); + }); + + it('rejects a function_call missing the name field', () => { + const result = PromptResponseLogValidator.safeParse({ + event_name: GEN_AI_USER_MESSAGE_EVENT, + body: { + role: 'user', + content: { + parts: [{ + function_call: { args: { location: 'London' } }, + }], + }, + }, + }); + expect(result.success).toBeFalse(); + }); + + it('rejects when content has no role at all', () => { + const result = PromptResponseLogValidator.safeParse({ + event_name: GEN_AI_CHOICE_EVENT, + body: { + content: { parts: [{ text: 'I have no role' }] }, + }, + }); + expect(result.success).toBeFalse(); + }); + + it('lifts top-level role onto content.role', () => { + const result = PromptResponseLogValidator.safeParse({ + event_name: GEN_AI_USER_MESSAGE_EVENT, + body: { + role: 'user', + content: { parts: [{ text: 'Hi' }] }, + }, + }); + expect(result.success).toBeTrue(); + if (result.success) { + expect(result.data.body.content.role).toBe('user'); + } + }); + }); + + describe('LogValidator union', () => { + it('accepts both system and prompt/response shapes', () => { + const sys = LogValidator.safeParse({ + event_name: GEN_AI_SYSTEM_MESSAGE_EVENT, + body: { content: 'sys' }, + }); + const user = LogValidator.safeParse({ + event_name: GEN_AI_USER_MESSAGE_EVENT, + body: { role: 'user', content: { parts: [{ text: 'hi' }] } }, + }); + expect(sys.success).toBeTrue(); + expect(user.success).toBeTrue(); + }); + }); + + describe('type guards', () => { + it('isUserMessageLog narrows on event_name', () => { + const log = { + event_name: GEN_AI_USER_MESSAGE_EVENT, + body: { content: { parts: [], role: 'user' } }, + } as const; + expect(isUserMessageLog(log as any)).toBeTrue(); + expect(isSystemMessageLog(log as any)).toBeFalse(); + expect(isChoiceLog(log as any)).toBeFalse(); + }); + + it('isSystemMessageLog narrows on event_name', () => { + const log = { + event_name: GEN_AI_SYSTEM_MESSAGE_EVENT, + body: { content: 'sys' }, + } as const; + expect(isSystemMessageLog(log as any)).toBeTrue(); + expect(isUserMessageLog(log as any)).toBeFalse(); + expect(isChoiceLog(log as any)).toBeFalse(); + }); + + it('isChoiceLog narrows on event_name', () => { + const log = { + event_name: GEN_AI_CHOICE_EVENT, + body: { content: { parts: [], role: 'model' } }, + } as const; + expect(isChoiceLog(log as any)).toBeTrue(); + expect(isUserMessageLog(log as any)).toBeFalse(); + expect(isSystemMessageLog(log as any)).toBeFalse(); + }); + }); +}); diff --git a/src/app/core/models/trace/StableSemconv.ts b/src/app/core/models/trace/StableSemconv.ts new file mode 100644 index 00000000..a3e849dd --- /dev/null +++ b/src/app/core/models/trace/StableSemconv.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Validators and types for the *stable* OpenTelemetry GenAI semantic + * conventions — i.e. the `gen_ai.system.message`, `gen_ai.user.message`, and + * `gen_ai.choice` log events emitted by the Python ADK from + * `google.adk.telemetry._stable_semconv`. + * + * This module is private to `src/app/core/models/trace/`. Public symbols are + * re-exported from `Trace.ts`. + */ + +import { z } from 'zod'; + +import { jsonStringSchema, withStrippedNulls } from './Shared'; + +// --------------------------------------------------------------------------- +// Event names — mirror constants in +// google.adk.telemetry._stable_semconv. +// --------------------------------------------------------------------------- + +export const GEN_AI_SYSTEM_MESSAGE_EVENT = 'gen_ai.system.message'; +export const GEN_AI_USER_MESSAGE_EVENT = 'gen_ai.user.message'; +export const GEN_AI_CHOICE_EVENT = 'gen_ai.choice'; + +// --------------------------------------------------------------------------- +// Body validators +// --------------------------------------------------------------------------- + +const FunctionCallValidator = withStrippedNulls(z.object({ + id: z.string().nullable().optional(), + name: z.string(), + args: z.record(z.string(), z.any()), + needsResponse: z.boolean().nullable().optional(), +})); + +const FunctionResponseValidator = withStrippedNulls(z.object({ + id: z.string().nullable().optional(), + name: z.string(), + response: z.record(z.string(), z.any()), +})); + +// This includes only the most common GenAI parts. +// Extra GenAI parts emitted, will not issue an error. +// If ADK Web needs to have type-safe access any of these, extend it. +const GenAIPartValidator = withStrippedNulls(z.object({ + text: z.string().nullable().optional(), + function_call: FunctionCallValidator.nullable().optional(), + function_response: FunctionResponseValidator.nullable().optional(), +})); + +const GenAIContentValidator = z.object({ + parts: z.array(GenAIPartValidator), + role: z.string(), +}); + +const GenAILogBodyValidator = z.object({ + content: z.object({ + parts: z.array(GenAIPartValidator), + role: z.string().optional(), + }), + role: z.string().optional(), +}).transform((logBody) => { + // Coherce role to be present on the content + const content = { ...logBody.content }; + if (logBody.role !== undefined) { + content.role = logBody.role; + } + return { + content: content, + }; +}).pipe(z.object({ + content: GenAIContentValidator, +})); + +const GenAISystemMessageBodyValidator = z.object({ + content: z.string(), +}); + +export const PromptResponseLogValidator = z.object({ + event_name: z.enum([GEN_AI_USER_MESSAGE_EVENT, GEN_AI_CHOICE_EVENT]), + body: z.union([ + GenAILogBodyValidator, + jsonStringSchema.pipe(GenAILogBodyValidator), + ]), +}); + +export const SystemMessageLogValidator = z.object({ + event_name: z.literal(GEN_AI_SYSTEM_MESSAGE_EVENT), + body: GenAISystemMessageBodyValidator, +}); + +export const LogValidator = z.union([ + SystemMessageLogValidator, + PromptResponseLogValidator, +]); + +// --------------------------------------------------------------------------- +// Inferred types & type guards +// --------------------------------------------------------------------------- + +export type ValidatedLog = z.infer; +export type PromptResponseLog = z.infer; +export type SystemMessageLog = z.infer; +export type GenAIContent = z.infer; + +export const isUserMessageLog = ( + log: ValidatedLog, +): log is PromptResponseLog => { + return log.event_name === GEN_AI_USER_MESSAGE_EVENT; +}; + +export const isSystemMessageLog = ( + log: ValidatedLog, +): log is SystemMessageLog => { + return log.event_name === GEN_AI_SYSTEM_MESSAGE_EVENT; +}; + +export const isChoiceLog = ( + log: ValidatedLog, +): log is PromptResponseLog => { + return log.event_name === GEN_AI_CHOICE_EVENT; +}; diff --git a/src/app/core/models/types.ts b/src/app/core/models/types.ts index fd4f538e..506ce305 100644 --- a/src/app/core/models/types.ts +++ b/src/app/core/models/types.ts @@ -138,4 +138,4 @@ export declare interface Event extends LlmResponse { export interface ComputerUsePayload { image?: {data: string; mimetype?: string;}; url?: string; -} \ No newline at end of file +} diff --git a/src/app/core/services/event.service.spec.ts b/src/app/core/services/event.service.spec.ts index a2278600..a64d8a4c 100644 --- a/src/app/core/services/event.service.spec.ts +++ b/src/app/core/services/event.service.spec.ts @@ -67,7 +67,7 @@ describe('EventService', () => { 'http://test.com/dev/apps/app1/debug/trace/session/session1', ); expect(req.request.method).toEqual('GET'); - req.flush({}); + req.flush([]); }); }); diff --git a/src/app/core/services/event.service.ts b/src/app/core/services/event.service.ts index b797a2c6..2040cdc6 100644 --- a/src/app/core/services/event.service.ts +++ b/src/app/core/services/event.service.ts @@ -19,9 +19,9 @@ import {HttpClient} from '@angular/common/http'; import {Injectable} from '@angular/core'; import {URLUtil} from '../../../utils/url-util'; import {EventIdentifier, EventService as EventServiceInterface} from './interfaces/event'; -import { EventTelemetry, Span } from '../models/Trace'; +import type { Observable } from 'rxjs'; +import { SpanValidator, Span } from '../models/Trace'; import {map} from 'rxjs/operators'; -import { normalizeEventTelemetry, normalizeSpan } from '../../../utils/trace-utils'; @Injectable({ providedIn: 'root', @@ -33,20 +33,23 @@ export class EventService implements EventServiceInterface { /** * Returns the trace data for a given event id. */ - getEventTrace(appName: string, event: EventIdentifier) { + getEventTrace(appName: string, event: EventIdentifier): Observable { const url = this.apiServerDomain + `/dev/apps/${appName}/debug/trace/${event.id!}`; - const eventTelemetry = this.http.get(url); - return eventTelemetry.pipe( - map(eventTelemetry => normalizeEventTelemetry(eventTelemetry)) - ); + return this.http.get(url); } - getTrace(appName: string, sessionId: string) { + getTrace(appName: string, sessionId: string): Observable { const url = this.apiServerDomain + `/dev/apps/${appName}/debug/trace/session/${sessionId}`; - const spans = this.http.get(url); + const spans = this.http.get(url); return spans.pipe( - map(spans => Array.isArray(spans) ? spans.map(normalizeSpan) : spans) - ); + map(spans => { + const result = SpanValidator.array().safeParse(spans); + if (!result.success) { + throw new Error(result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')); + } else { + return result.data; + } + })); } getEvent( diff --git a/src/app/core/services/interfaces/event.ts b/src/app/core/services/interfaces/event.ts index 8e1f4cc8..dc3f5a0c 100644 --- a/src/app/core/services/interfaces/event.ts +++ b/src/app/core/services/interfaces/event.ts @@ -18,6 +18,7 @@ import {Event} from '../../models/types'; import {InjectionToken} from '@angular/core'; import {Observable} from 'rxjs'; +import type { Span } from '../../models/Trace'; export const EVENT_SERVICE = new InjectionToken('EventService'); @@ -33,7 +34,7 @@ export type EventIdentifier = Pick; */ export declare abstract class EventService { abstract getEventTrace(appName: string, event: EventIdentifier): Observable; - abstract getTrace(appName: string, sessionId: string): Observable; + abstract getTrace(appName: string, sessionId: string): Observable; abstract getEvent( userId: string, appName: string, diff --git a/src/app/core/services/trace.service.spec.ts b/src/app/core/services/trace.service.spec.ts index a8e55445..d1f90146 100644 --- a/src/app/core/services/trace.service.spec.ts +++ b/src/app/core/services/trace.service.spec.ts @@ -48,7 +48,8 @@ describe('TraceService', () => { parent_span_id: undefined, start_time: 0, end_time: 0, - attributes: {}, + rawAttributesUseThisFieldOnlyForDisplay: {}, + rawSpanUseThisFieldOnlyForDisplay: {}, }; service.selectedRow(span); const selectedSpan = await firstValueFrom(service.selectedTraceRow$); diff --git a/src/assets/config/runtime-config.json b/src/assets/config/runtime-config.json index c6732406..71ca9149 100644 --- a/src/assets/config/runtime-config.json +++ b/src/assets/config/runtime-config.json @@ -1,3 +1,3 @@ { - "backendUrl": "http://localhost:8000" + "backendUrl": "http://127.0.0.1:8000" } \ No newline at end of file diff --git a/src/utils/trace-utils.spec.ts b/src/utils/trace-utils.spec.ts deleted file mode 100644 index 0bad84f1..00000000 --- a/src/utils/trace-utils.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it} -import { normalizeSpan, normalizeEventTelemetry } from './trace-utils'; -import { Span, EventTelemetry, Log } from '../app/core/models/Trace'; - -describe('trace-utils', () => { - describe('normalizeSpan', () => { - it('should keep existing llm_request and llm_response', () => { - const span: Span = { - name: 'test', - start_time: 0, - end_time: 100, - span_id: 's1', - trace_id: 't1', - attributes: { - 'gcp.vertex.agent.llm_request': 'existing request', - 'gcp.vertex.agent.llm_response': 'existing response', - } - }; - const normalized = normalizeSpan(span); - expect(normalized.attributes['gcp.vertex.agent.llm_request']).toBe('existing request'); - expect(normalized.attributes['gcp.vertex.agent.llm_response']).toBe('existing response'); - }); - - it('should extract input and output for execute_tool spans from attributes', () => { - const span: Span = { - name: 'execute_tool_something', - start_time: 0, - end_time: 100, - span_id: 's1', - trace_id: 't1', - attributes: { - 'gcp.vertex.agent.tool_call_args': '{"arg": "val"}', - 'gcp.vertex.agent.tool_response': '{"result": "ok"}' - } - }; - const normalized = normalizeSpan(span); - expect(normalized.attributes['gcp.vertex.agent.llm_request']).toBe('{"arg": "val"}'); - expect(normalized.attributes['gcp.vertex.agent.llm_response']).toBe('{"result": "ok"}'); - }); - - it('should extract input from logs for non-execute_tool spans', () => { - const logs: Log[] = [ - { - event_name: 'gen_ai.system.message', - body: JSON.stringify({ role: 'system', content: 'system instructions' }), - trace_id: 't1', - span_id: 's1' - }, - { - event_name: 'gen_ai.user.message', - body: JSON.stringify({ content: { parts: [{ text: 'hello' }] } }), - trace_id: 't1', - span_id: 's1' - } - ]; - const span: Span = { - name: 'generate_content', - start_time: 0, - end_time: 100, - span_id: 's1', - trace_id: 't1', - logs: logs, - attributes: {} - }; - const normalized = normalizeSpan(span); - const userMessage = JSON.parse(normalized.attributes['gcp.vertex.agent.llm_request']!); - expect(userMessage).toEqual({ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] }); - }); - - it('should ignore user messages with function responses', () => { - const logs: Log[] = [{ - event_name: 'gen_ai.user.message', - body: JSON.stringify({ - content: {parts: [{functionResponse: {name: 'foo', response: {}}}]} - }), - trace_id: 't1', - span_id: 's1' - }]; - const span: Span = { - name: 'generate_content', - start_time: 0, - end_time: 100, - span_id: 's1', - trace_id: 't1', - logs: logs, - attributes: {} - }; - const normalized = normalizeSpan(span); - expect('gcp.vertex.agent.llm_request' in normalized.attributes) - .toBeFalse(); - }); - - it('should extract output from logs for non-execute_tool spans', () => { - const logs: Log[] = [ - { - event_name: 'gen_ai.choice', - body: 'model response', - trace_id: 't1', - span_id: 's1' - } - ]; - const span: Span = { - name: 'generate_content', - start_time: 0, - end_time: 100, - span_id: 's1', - trace_id: 't1', - logs: logs, - attributes: {} - }; - const normalized = normalizeSpan(span); - expect(normalized.attributes['gcp.vertex.agent.llm_response']).toBe('model response'); - }); - - it('should handle missing logs gracefully', () => { - const span: Span = { - name: 'generate_content', - start_time: 0, - end_time: 100, - span_id: 's1', - trace_id: 't1', - attributes: {} - }; - const normalized = normalizeSpan(span); - expect('gcp.vertex.agent.llm_request' in normalized.attributes) - .toBeFalse(); - expect('gcp.vertex.agent.llm_response' in normalized.attributes) - .toBeFalse(); - }); - - it('should handle missing attributes gracefully', () => { - const span: Span = { - name: 'generate_content', - start_time: 0, - end_time: 100, - span_id: 's1', - trace_id: 't1', - logs: [] - }; - const normalized = normalizeSpan(span); - expect('gcp.vertex.agent.llm_request' in normalized.attributes) - .toBeFalse(); - expect('gcp.vertex.agent.llm_response' in normalized.attributes) - .toBeFalse(); - }); - }); - - describe('normalizeEventTelemetry', () => { - it('should keep existing llm_request and llm_response', () => { - const telemetry: EventTelemetry = { - 'gcp.vertex.agent.llm_request': 'existing request', - 'gcp.vertex.agent.llm_response': 'existing response', - }; - const normalized = normalizeEventTelemetry(telemetry); - expect(normalized['gcp.vertex.agent.llm_request']).toBe('existing request'); - expect(normalized['gcp.vertex.agent.llm_response']).toBe('existing response'); - }); - - it('should extract input and output from telemetry properties if present', () => { - const telemetry: any = { - 'gcp.vertex.agent.tool_call_args': '{"arg": "val"}', - 'gcp.vertex.agent.tool_response': '{"result": "ok"}' - }; - const normalized = normalizeEventTelemetry(telemetry); - expect(normalized['gcp.vertex.agent.llm_request']).toBe('{"arg": "val"}'); - expect(normalized['gcp.vertex.agent.llm_response']).toBe('{"result": "ok"}'); - }); - - it('should extract from logs if attributes are missing', () => { - const logs: Log[] = [ - { - event_name: 'gen_ai.choice', - body: 'log response', - trace_id: 't1', - span_id: 's1' - } - ]; - const telemetry: EventTelemetry = { - logs: logs - }; - const normalized = normalizeEventTelemetry(telemetry); - expect(normalized['gcp.vertex.agent.llm_response']).toBe('log response'); - }); - }); -}); diff --git a/src/utils/trace-utils.ts b/src/utils/trace-utils.ts deleted file mode 100644 index c457501a..00000000 --- a/src/utils/trace-utils.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { EventTelemetry, Log, Span } from "../app/core/models/Trace"; - -const GCP_VERTEX_AGENT_TOOL_CALL_ARGS = 'gcp.vertex.agent.tool_call_args'; -const GCP_VERTEX_AGENT_TOOL_RESPONSE = 'gcp.vertex.agent.tool_response'; - -const GCP_VERTEX_AGENT_LLM_REQUEST = 'gcp.vertex.agent.llm_request' -const GCP_VERTEX_AGENT_LLM_RESPONSE = 'gcp.vertex.agent.llm_response' - -const EXECUTE_TOOL = 'execute_tool'; -const GENERATE_CONTENT = 'generate_content'; - -const CONTENT = 'content'; -const PARTS = 'parts'; -const FUNCTION_RESPONSE = 'functionResponse'; - -// Normalizes span such that input/output ends up in the same place for: -// - semconv `generate_content` spans -// - semconv `execute_tool` spans -// - legacy `call_llm` spans -export const normalizeSpan = (span: Span): Span => { - const normalized = { - ...span, - attributes: { - ...span.attributes, - } - }; - const request = span?.attributes?.[GCP_VERTEX_AGENT_LLM_REQUEST] ?? - extractInputFromSpan(span) - const response = span?.attributes?.[GCP_VERTEX_AGENT_LLM_RESPONSE] ?? - extractOutputFromSpan(span) - if (request !== undefined) { - normalized.attributes[GCP_VERTEX_AGENT_LLM_REQUEST] = request - } - if (response !== undefined) { - normalized.attributes[GCP_VERTEX_AGENT_LLM_RESPONSE] = response - } - return normalized; -}; - -const extractInputFromSpan = (span: Span): {[key: string]: any} | string | undefined => { - if (span.name.startsWith(EXECUTE_TOOL)) { - return span.attributes?.[GCP_VERTEX_AGENT_TOOL_CALL_ARGS]; - } else if (span.name.startsWith(GENERATE_CONTENT)) { - // Note, here we only extract the user message part of the input. - // This is because later on, UI expects this to be in the google-genai content format. - // Ideally it would be unified between here and extractInputFromLogs. - return extractUserMessageFromSpan(span.logs); - } else { - return undefined; - } -}; - -const extractInputFromLogs = (logs?: Log[]): string => { - const systemMessage = extractSystemMessageFromLogs(logs); - const userMessage = extractUserMessageFromSpan(logs); - return JSON.stringify({ - system_message: systemMessage, - user_message: tryJSONParse(userMessage), - }); -}; - -const extractUserMessageFromSpan = (logs?: Log[]): string | undefined => { - if (!logs) { - return undefined; - } - const userMessageLog = logs.reverse().find(isUserMessageLog); - if (!userMessageLog) { - return undefined; - } - const userMessage = typeof userMessageLog.body === 'string' ? tryJSONParse(userMessageLog.body) : userMessageLog.body - if (typeof userMessage === 'string') { - return userMessage - } - // Modifying the object, to fix processing further down. - // Modifying login in src/app/components/trace-tab/trace-tab.component.ts would be more ideal. - userMessage['content']['role'] = 'user'; - userMessage['contents'] = [userMessage['content']] - delete userMessage['content']; - return JSON.stringify(userMessage); -}; - -const extractSystemMessageFromLogs = (logs?: Log[]): {[key: string]: any} | string | undefined => { - if (!logs) { - return undefined; - } - const systemMessageLog = logs.reverse().find(log => log.event_name === 'gen_ai.system.message') - if (!systemMessageLog) { - return undefined; - } - if (typeof systemMessageLog.body === 'string') { - return tryJSONParse(systemMessageLog.body); - } else { - return systemMessageLog.body; - } -}; - -const isUserMessageLog = (log: Log): boolean => { - if (log.event_name !== 'gen_ai.user.message') { - return false; - } - - try { - const body = typeof log.body === 'string' ? JSON.parse(log.body) : log.body; - const parts = (body as any)[CONTENT]?.[PARTS]; - if (!Array.isArray(parts)) { - return false; - } - return parts.every(part => !part[FUNCTION_RESPONSE]); - } - catch (e) { - return false; - } -}; - -const extractOutputFromSpan = (span: Span): string | undefined => { - if (span.name.startsWith(EXECUTE_TOOL)) { - return span.attributes?.[GCP_VERTEX_AGENT_TOOL_RESPONSE]; - } else if (span.name.startsWith(GENERATE_CONTENT)) { - return extractOutputFromLogs(span.logs); - } else { - return undefined; - } -}; - -const extractOutputFromLogs = (logs?: Log[]): string | undefined => { - if (!logs) { - return undefined; - } - const genaiChoiceLog = logs.reverse().find(log => log.event_name === 'gen_ai.choice'); - if (!genaiChoiceLog) { - return undefined; - } - return stringFromLogBody(genaiChoiceLog); -}; - -const tryJSONParse = (maybeJSON: any): any | string => { - try { - return JSON.parse(maybeJSON); - } catch (e) { - return maybeJSON; - } -}; - -const stringFromLogBody = (log: Log): string => { - return typeof log.body === 'string' ? log.body : JSON.stringify(log.body); -}; - -// Normalizes event telemetry such that event input/output ends up in the same place for: -// - semconv `generate_content` spans -// - semconv `execute_tool` spans -// - legacy `call_llm` spans -export const normalizeEventTelemetry = (telemetry: EventTelemetry) => { - const request = - telemetry[GCP_VERTEX_AGENT_LLM_REQUEST] ?? extractEventInput(telemetry) - const response = - telemetry[GCP_VERTEX_AGENT_LLM_RESPONSE] ?? extractEventOutput(telemetry) - const normalizedEvent = { - ...telemetry, - }; - if (request !== undefined) { - normalizedEvent[GCP_VERTEX_AGENT_LLM_REQUEST] = request; - } - if (response !== undefined) { - normalizedEvent[GCP_VERTEX_AGENT_LLM_RESPONSE] = response; - } - return normalizedEvent; -}; - -const extractEventInput = (telemetry: EventTelemetry): string | undefined => { - if (GCP_VERTEX_AGENT_TOOL_CALL_ARGS in telemetry) { - return `${telemetry[GCP_VERTEX_AGENT_TOOL_CALL_ARGS]}`; - } - if (!telemetry.logs) { - return undefined; - } - return extractInputFromLogs(telemetry.logs); -}; - -const extractEventOutput = (telemetry: EventTelemetry): string | undefined => { - if (GCP_VERTEX_AGENT_TOOL_RESPONSE in telemetry) { - return `${telemetry[GCP_VERTEX_AGENT_TOOL_RESPONSE]}`; - } - if (!telemetry.logs) { - return undefined; - } - return extractOutputFromLogs(telemetry.logs); -} From 2892b68f1296e9646c1949c5870d2828b5144db6 Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Fri, 22 May 2026 14:08:46 -0700 Subject: [PATCH 24/30] feat: implement SSE request cancellation and UI stop functionality for agent responses --- .../call-controls.component.html | 14 ++++----- .../call-controls.component.scss | 10 +++++-- .../call-controls/call-controls.component.ts | 1 + .../chat-panel/chat-panel.component.html | 23 +++++++++----- .../chat-panel/chat-panel.component.i18n.ts | 1 + .../chat-panel/chat-panel.component.scss | 18 +++++++++-- .../chat-panel/chat-panel.component.ts | 1 + src/app/components/chat/chat.component.html | 1 + src/app/components/chat/chat.component.ts | 30 ++++++++++++++----- src/app/core/services/agent.service.spec.ts | 1 + src/app/core/services/agent.service.ts | 19 +++++++++++- 11 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/app/components/call-controls/call-controls.component.html b/src/app/components/call-controls/call-controls.component.html index 698e519e..ab7aed8b 100644 --- a/src/app/components/call-controls/call-controls.component.html +++ b/src/app/components/call-controls/call-controls.component.html @@ -5,7 +5,7 @@ class="video-rec-btn" [class.recording]="isVideoRecording" [matTooltip]="isVideoRecording ? i18n.turnOffCamTooltip : i18n.useCamTooltip" - [disabled]="!isBidiStreamingEnabled" + [disabled]="disabled || !isBidiStreamingEnabled" > videocam @@ -22,25 +22,25 @@ (click)="onCallClick()" class="audio-rec-btn" [class.recording]="isAudioRecording" - [disabled]="!isBidiStreamingEnabled" + [disabled]="disabled || !isBidiStreamingEnabled" > {{ isAudioRecording ? 'call_end' : 'call' }} - @if (showFlags && !isAudioRecording) { + @if (showFlags && !isAudioRecording && !disabled) {
Live Flags
- Proactive Audio + Proactive Audio
- Affective Dialog + Affective Dialog
- Session Resumption + Session Resumption
- Save Live Blob + Save Live Blob
} diff --git a/src/app/components/call-controls/call-controls.component.scss b/src/app/components/call-controls/call-controls.component.scss index ff6f5975..1af9bf09 100644 --- a/src/app/components/call-controls/call-controls.component.scss +++ b/src/app/components/call-controls/call-controls.component.scss @@ -10,7 +10,7 @@ background-color: var(--mat-sys-surface-variant); } -button { +button:not(:disabled) { color: var(--mat-sys-on-surface-variant) !important; &.recording { @@ -19,10 +19,16 @@ button { } } -button.audio-rec-btn:not(.recording) { +button.audio-rec-btn:not(.recording):not(:disabled) { color: #34a853 !important; } +button:disabled { + color: var(--mat-sys-on-surface-variant) !important; + opacity: 0.38 !important; + cursor: not-allowed; +} + .mic-visualizer { display: flex; align-items: center; diff --git a/src/app/components/call-controls/call-controls.component.ts b/src/app/components/call-controls/call-controls.component.ts index ff1f6c59..9654a59e 100644 --- a/src/app/components/call-controls/call-controls.component.ts +++ b/src/app/components/call-controls/call-controls.component.ts @@ -40,6 +40,7 @@ export class CallControlsComponent { @Input() isVideoRecording = false; @Input() micVolume = 0; @Input() isBidiStreamingEnabled: boolean | null = false; + @Input() disabled = false; @Output() readonly toggleAudioRecording = new EventEmitter(); @Output() readonly toggleVideoRecording = new EventEmitter(); diff --git a/src/app/components/chat-panel/chat-panel.component.html b/src/app/components/chat-panel/chat-panel.component.html index 9c7bf189..65179ae4 100644 --- a/src/app/components/chat-panel/chat-panel.component.html +++ b/src/app/components/chat-panel/chat-panel.component.html @@ -178,23 +178,31 @@

Evaluation Result

} - + (keydown.enter)="!isLoadingAgentResponse() && sendMessage.emit($event)" [placeholder]="i18n.typeMessagePlaceholder" + [disabled]="isLoadingAgentResponse() ?? false"> + @if (isLoadingAgentResponse()) { + + } @else { + + }
@if (!hideMoreOptionsButton()) { @@ -207,6 +215,7 @@

Evaluation Result

diff --git a/src/app/components/chat-panel/chat-panel.component.i18n.ts b/src/app/components/chat-panel/chat-panel.component.i18n.ts index 1f3ca25b..5b4c682e 100644 --- a/src/app/components/chat-panel/chat-panel.component.i18n.ts +++ b/src/app/components/chat-panel/chat-panel.component.i18n.ts @@ -39,6 +39,7 @@ export const CHAT_PANEL_MESSAGES = { editFunctionArgsTooltip: 'Edit function arguments', typeMessagePlaceholder: 'Type a message...', sendMessageTooltip: 'Send message', + stopMessageTooltip: 'Stop', uploadFileTooltip: 'Upload local file', moreOptionsTooltip: 'More options', updateStateMenuLabel: 'Update state', diff --git a/src/app/components/chat-panel/chat-panel.component.scss b/src/app/components/chat-panel/chat-panel.component.scss index 09cd3b2c..93f3956a 100644 --- a/src/app/components/chat-panel/chat-panel.component.scss +++ b/src/app/components/chat-panel/chat-panel.component.scss @@ -223,11 +223,25 @@ } } - button { + button:not(:disabled):not(.stop-message-btn) { color: var(--mat-sys-primary) !important; } } +button.stop-message-btn:not(:disabled) { + color: #ea4335 !important; + + &:hover { + background-color: rgba(234, 67, 53, 0.08) !important; + } +} + +button:disabled { + color: var(--mat-sys-on-surface-variant) !important; + opacity: 0.38 !important; + cursor: not-allowed; +} + .chat-input-actions { width: 100%; margin-top: 10px; @@ -235,7 +249,7 @@ justify-content: space-between; align-items: center; - button { + button:not(:disabled) { color: var(--mat-sys-on-surface-variant) !important; &.recording { diff --git a/src/app/components/chat-panel/chat-panel.component.ts b/src/app/components/chat-panel/chat-panel.component.ts index 7fab3ef7..291a6c25 100644 --- a/src/app/components/chat-panel/chat-panel.component.ts +++ b/src/app/components/chat-panel/chat-panel.component.ts @@ -173,6 +173,7 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { @Output() readonly removeFile = new EventEmitter(); @Output() readonly removeStateUpdate = new EventEmitter(); @Output() readonly sendMessage = new EventEmitter(); + @Output() readonly stopMessage = new EventEmitter(); @Output() readonly updateState = new EventEmitter(); @Output() readonly toggleAudioRecording = new EventEmitter(); @Output() readonly toggleVideoRecording = new EventEmitter(); diff --git a/src/app/components/chat/chat.component.html b/src/app/components/chat/chat.component.html index cab97598..e67f1e8b 100644 --- a/src/app/components/chat/chat.component.html +++ b/src/app/components/chat/chat.component.html @@ -601,6 +601,7 @@ (removeFile)="removeFile($event)" (removeStateUpdate)="removeStateUpdate()" (sendMessage)="handleChatInput($event)" + (stopMessage)="handleStopMessage()" (updateState)="updateState()" (toggleAudioRecording)="toggleAudioRecording($event)" (toggleVideoRecording)="toggleVideoRecording()" diff --git a/src/app/components/chat/chat.component.ts b/src/app/components/chat/chat.component.ts index c9d0c8a6..46a5c50f 100644 --- a/src/app/components/chat/chat.component.ts +++ b/src/app/components/chat/chat.component.ts @@ -37,7 +37,7 @@ import { MatToolbar } from '@angular/material/toolbar'; import { SafeHtml } from '@angular/platform-browser'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { CustomJsonViewerComponent } from '../custom-json-viewer/custom-json-viewer.component'; -import { combineLatest, firstValueFrom, Observable, of } from 'rxjs'; +import { combineLatest, firstValueFrom, Observable, of, Subscription } from 'rxjs'; import { catchError, distinctUntilChanged, filter, first, map, shareReplay, startWith, switchMap, take, tap } from 'rxjs/operators'; import { URLUtil } from '../../../utils/url-util'; @@ -206,11 +206,13 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { private readonly traceService = inject(TRACE_SERVICE); protected readonly uiStateService = inject(UI_STATE_SERVICE); protected readonly agentBuilderService = inject(AGENT_BUILDER_SERVICE); - protected readonly themeService = inject(THEME_SERVICE, {optional: true}); + protected readonly themeService = inject(THEME_SERVICE, { optional: true }); protected readonly logoComponent: Type | null = inject(LOGO_COMPONENT, { optional: true, }); + private activeSseSubscription?: Subscription; + chatPanel = viewChild(ChatPanelComponent); canvasComponent = viewChild.required(CanvasComponent); sideDrawer = viewChild.required('sideDrawer'); @@ -1108,7 +1110,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { submitAgentRunRequest(req: AgentRunRequest) { this.autoSelectLatestEvent = true; - this.agentService.runSse(req).subscribe({ + this.activeSseSubscription = this.agentService.runSse(req).subscribe({ next: async (chunkJson: any) => { if (chunkJson.error) { this.openSnackBar(chunkJson.error, 'OK'); @@ -1128,10 +1130,16 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.changeDetectorRef.detectChanges(); }, error: (err) => { + this.activeSseSubscription = undefined; console.error('Send message error:', err); + const errString = String(err); + if (errString.includes('aborted') || errString.includes('AbortError')) { + return; + } this.openSnackBar(err, 'OK'); }, complete: () => { + this.activeSseSubscription = undefined; if (this.updatedSessionState()) { this.currentSessionState = this.updatedSessionState(); this.updatedSessionState.set(null); @@ -1148,6 +1156,13 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { }); } + handleStopMessage() { + if (this.activeSseSubscription) { + this.activeSseSubscription.unsubscribe(); + this.activeSseSubscription = undefined; + } + } + private appendEventRow(apiEvent: any, reverseOrder: boolean = false) { if (apiEvent.inputTranscription !== undefined) { apiEvent.author = 'user'; @@ -1789,6 +1804,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy(): void { + this.handleStopMessage(); this.streamChatService.closeStream(); } @@ -3600,14 +3616,14 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { if (eventId === undefined) return undefined; const generateContentSpan = this.traceData?.find( - (span) => - span.attrOperationName === OPERATION_GENERATE_CONTENT - && span.attrEventId === eventId + (span) => + span.attrOperationName === OPERATION_GENERATE_CONTENT + && span.attrEventId === eventId ); if (generateContentSpan?.io !== undefined) return generateContentSpan.io; const legacySpan = this.traceData?.find( - (span) => span.attrEventId === eventId && span.name === 'call_llm', + (span) => span.attrEventId === eventId && span.name === 'call_llm', ); return legacySpan?.io; } diff --git a/src/app/core/services/agent.service.spec.ts b/src/app/core/services/agent.service.spec.ts index b4e0bcae..f3cd73b1 100644 --- a/src/app/core/services/agent.service.spec.ts +++ b/src/app/core/services/agent.service.spec.ts @@ -145,6 +145,7 @@ describe('AgentService', () => { [HEADER_ACCEPT]: TEXT_EVENT_STREAM, }, body: JSON.stringify(RUN_SSE_PAYLOAD), + signal: jasmine.any(AbortSignal), }); }); diff --git a/src/app/core/services/agent.service.ts b/src/app/core/services/agent.service.ts index 95940006..7d10e09d 100644 --- a/src/app/core/services/agent.service.ts +++ b/src/app/core/services/agent.service.ts @@ -54,6 +54,10 @@ export class AgentService implements AgentServiceInterface { this.isLoading.next(true); return new Observable((observer) => { const self = this; + const controller = new AbortController(); + const signal = controller.signal; + let reader: ReadableStreamDefaultReader | undefined; + fetch(url, { method: 'POST', headers: { @@ -61,9 +65,10 @@ export class AgentService implements AgentServiceInterface { 'Accept': 'text/event-stream', }, body: JSON.stringify(req), + signal, }) .then((response) => { - const reader = response.body?.getReader(); + reader = response.body?.getReader(); const decoder = new TextDecoder('utf-8'); let lastData = ''; @@ -96,6 +101,9 @@ export class AgentService implements AgentServiceInterface { read(); // Read the next chunk }) .catch((err) => { + if (signal.aborted) { + return; + } self.zone.run(() => observer.error(err)); }); }; @@ -103,8 +111,17 @@ export class AgentService implements AgentServiceInterface { read(); }) .catch((err) => { + if (signal.aborted) { + return; + } self.zone.run(() => observer.error(err)); }); + + return () => { + controller.abort(); + reader?.cancel(); + this.isLoading.next(false); + }; }); } From 82627918ae2da8744aed87d95258c795b14db602 Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Fri, 22 May 2026 14:21:01 -0700 Subject: [PATCH 25/30] refactor: migrate chat input controls into integrated menu and suffix layout within mat-form-field --- .../chat-panel/chat-panel.component.html | 74 +++++++++---------- .../chat-panel/chat-panel.component.scss | 34 +++++---- .../chat-panel/chat-panel.component.spec.ts | 16 +++- .../chat-panel/chat-panel.component.ts | 7 ++ 4 files changed, 75 insertions(+), 56 deletions(-) diff --git a/src/app/components/chat-panel/chat-panel.component.html b/src/app/components/chat-panel/chat-panel.component.html index 65179ae4..2f5405f1 100644 --- a/src/app/components/chat-panel/chat-panel.component.html +++ b/src/app/components/chat-panel/chat-panel.component.html @@ -176,50 +176,50 @@

Evaluation Result

}
} + + + + + @if (!hideMoreOptionsButton()) { + + } + + - @if (isLoadingAgentResponse()) { - - } @else { - - } + +
+ + @if (isLoadingAgentResponse()) { + + } @else { + + } +
-
-
- - @if (!hideMoreOptionsButton()) { - - - {{ - i18n.updateStateMenuLabel }} - - - } -
-
- -
-
} diff --git a/src/app/components/chat-panel/chat-panel.component.scss b/src/app/components/chat-panel/chat-panel.component.scss index 93f3956a..8e8635ab 100644 --- a/src/app/components/chat-panel/chat-panel.component.scss +++ b/src/app/components/chat-panel/chat-panel.component.scss @@ -226,6 +226,17 @@ button:not(:disabled):not(.stop-message-btn) { color: var(--mat-sys-primary) !important; } + + ::ng-deep { + .mat-mdc-form-field-flex { + align-items: flex-end !important; + } + .mat-mdc-form-field-icon-prefix, + .mat-mdc-form-field-icon-suffix { + align-self: flex-end !important; + margin-bottom: 8px !important; + } + } } button.stop-message-btn:not(:disabled) { @@ -242,28 +253,19 @@ button:disabled { cursor: not-allowed; } -.chat-input-actions { - width: 100%; - margin-top: 10px; - display: flex; - justify-content: space-between; - align-items: center; +button.input-prefix-menu-btn { + margin-left: 12px !important; - button:not(:disabled) { + &:not(:disabled) { color: var(--mat-sys-on-surface-variant) !important; - - &.recording { - background-color: var(--mat-sys-error) !important; - color: var(--mat-sys-on-error, #ffffff) !important; - } } } -.chat-input-actions-left, -.chat-input-actions-right { +.input-suffix-container { display: flex; - align-items: center; - gap: 4px; + align-items: flex-end; + gap: 8px; + margin-right: 12px !important; } .file-preview { 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 94681798..c58d5999 100644 --- a/src/app/components/chat-panel/chat-panel.component.spec.ts +++ b/src/app/components/chat-panel/chat-panel.component.spec.ts @@ -550,8 +550,13 @@ describe('ChatPanelComponent', () => { mockFeatureFlagService.isMessageFileUploadEnabledResponse.next(false); fixture.detectChanges(); + // Open the actions menu + const prefixButton = fixture.debugElement.query(By.css('.input-prefix-menu-btn')); + prefixButton.nativeElement.click(); + fixture.detectChanges(); + const allButtons = - fixture.debugElement.queryAll(By.css('button[mat-icon-button]')); + fixture.debugElement.queryAll(By.css('button[mat-menu-item]')); const button = allButtons.find( b => b.nativeElement.querySelector('mat-icon')?.textContent?.trim() === @@ -563,12 +568,17 @@ describe('ChatPanelComponent', () => { mockFeatureFlagService.isManualStateUpdateEnabledResponse.next(false); fixture.detectChanges(); + // Open the actions menu + const prefixButton = fixture.debugElement.query(By.css('.input-prefix-menu-btn')); + prefixButton.nativeElement.click(); + fixture.detectChanges(); + const allButtons = - fixture.debugElement.queryAll(By.css('button[mat-icon-button]')); + fixture.debugElement.queryAll(By.css('button[mat-menu-item]')); const button = allButtons.find( b => b.nativeElement.querySelector('mat-icon')?.textContent?.trim() === - 'more_vert'); + 'tune'); expect(button!.nativeElement.disabled).toBeTrue(); }); diff --git a/src/app/components/chat-panel/chat-panel.component.ts b/src/app/components/chat-panel/chat-panel.component.ts index 291a6c25..afaa091c 100644 --- a/src/app/components/chat-panel/chat-panel.component.ts +++ b/src/app/components/chat-panel/chat-panel.component.ts @@ -246,6 +246,13 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { }); } }); + + effect(() => { + const isLoading = this.isLoadingAgentResponse(); + if (!isLoading) { + this.focusInput(); + } + }); } ngOnInit() { From 3f5af777785386e59fbabe693ead2d581d13ea05 Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Tue, 26 May 2026 12:25:04 -0700 Subject: [PATCH 26/30] feat: format usage metadata tokens with decimal pipe and improve layout for numeric values --- .../event-tab/event-tab.component.html | 18 ++++-- .../event-tab/event-tab.component.scss | 64 +++++++++++++++++++ .../event-tab/event-tab.component.spec.ts | 24 +++++++ .../event-tab/event-tab.component.ts | 19 +++++- 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/app/components/event-tab/event-tab.component.html b/src/app/components/event-tab/event-tab.component.html index d2989452..9356b690 100644 --- a/src/app/components/event-tab/event-tab.component.html +++ b/src/app/components/event-tab/event-tab.component.html @@ -207,18 +207,24 @@ {{ key }} - + @if (key === 'promptTokensDetails' || key === 'promptTokenDetails' || key === 'candidatesTokenDetails' || key === 'candidatesTokensDetails' || key === 'cacheTokensDetails') { @for (detail of selectedEvent()!.usageMetadata[key]; track detail.modality) { -
{{ detail.modality }}: {{ detail.tokenCount }}
+
+ {{ detail.modality }} + {{ detail.tokenCount | number }} +
} } @else { + @if (isNumber(selectedEvent()!.usageMetadata[key])) { + {{ selectedEvent()!.usageMetadata[key] | number }} + } @else { {{ selectedEvent()!.usageMetadata[key] }} } + }
-
@@ -237,15 +243,15 @@ - + - + - +
Total Prompt Tokens{{ sessionUsageMetadata()['Prompt Tokens'] }}{{ sessionUsageMetadata()['Prompt Tokens'] | number }}
Total Candidates Tokens{{ sessionUsageMetadata()['Candidates Tokens'] }}{{ sessionUsageMetadata()['Candidates Tokens'] | number }}
Total Tokens{{ sessionUsageMetadata()['Total Tokens'] }}{{ sessionUsageMetadata()['Total Tokens'] | number }}
diff --git a/src/app/components/event-tab/event-tab.component.scss b/src/app/components/event-tab/event-tab.component.scss index 203a03f6..c00bdbcd 100644 --- a/src/app/components/event-tab/event-tab.component.scss +++ b/src/app/components/event-tab/event-tab.component.scss @@ -250,6 +250,70 @@ } } +.numeric-cell { + text-align: right !important; +} + +.value-cell.numeric-cell { + justify-content: flex-end; + + > :first-child { + text-align: right; + font-family: 'Google Sans Mono', monospace; + font-size: 13px; + font-weight: 500; + color: var(--mat-sys-on-surface); + + span { + font-family: 'Google Sans Mono', monospace; + } + } +} + +td.numeric-cell { + text-align: right !important; + font-family: 'Google Sans Mono', monospace !important; + font-size: 13px !important; + font-weight: 500 !important; + color: var(--mat-sys-on-surface) !important; +} + +.detail-row { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + margin-bottom: 4px; + font-size: 12px; + transition: transform 0.15s ease-in-out; + + &:hover { + transform: translateX(-2px); + } + + &:last-child { + margin-bottom: 0; + } + + .modality-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 4px; + color: var(--mat-sys-primary); + background-color: var(--mat-sys-primary-container); + opacity: 0.85; + } + + .modality-value { + font-weight: 500; + font-family: 'Google Sans Mono', monospace; + color: var(--mat-sys-on-surface); + } +} + .copy-id-button, .copy-value-button { width: 28px !important; diff --git a/src/app/components/event-tab/event-tab.component.spec.ts b/src/app/components/event-tab/event-tab.component.spec.ts index a911514a..b1f76d45 100644 --- a/src/app/components/event-tab/event-tab.component.spec.ts +++ b/src/app/components/event-tab/event-tab.component.spec.ts @@ -158,4 +158,28 @@ describe('EventTabComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('numeric helpers', () => { + it('isNumber should return true for numbers and false for others', () => { + expect(component.isNumber(123)).toBeTrue(); + expect(component.isNumber(0)).toBeTrue(); + expect(component.isNumber(-45.6)).toBeTrue(); + expect(component.isNumber('123')).toBeFalse(); + expect(component.isNumber(null)).toBeFalse(); + expect(component.isNumber(undefined)).toBeFalse(); + expect(component.isNumber({})).toBeFalse(); + expect(component.isNumber([])).toBeFalse(); + }); + + it('isNumericValue should identify numbers and specific detail token keys', () => { + expect(component.isNumericValue('promptTokenCount', 123)).toBeTrue(); + expect(component.isNumericValue('promptTokensDetails', [])).toBeTrue(); + expect(component.isNumericValue('candidatesTokenDetails', [])).toBeTrue(); + expect(component.isNumericValue('cacheTokensDetails', [])).toBeTrue(); + expect(component.isNumericValue('candidatesTokensDetails', [])).toBeTrue(); + expect(component.isNumericValue('promptTokenDetails', [])).toBeTrue(); + expect(component.isNumericValue('otherKey', {})).toBeFalse(); + expect(component.isNumericValue('otherKey', '123')).toBeFalse(); + }); + }); }); diff --git a/src/app/components/event-tab/event-tab.component.ts b/src/app/components/event-tab/event-tab.component.ts index e5398860..d3398c99 100644 --- a/src/app/components/event-tab/event-tab.component.ts +++ b/src/app/components/event-tab/event-tab.component.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import {AsyncPipe, DatePipe, KeyValuePipe} from '@angular/common'; +import {AsyncPipe, DatePipe, DecimalPipe, KeyValuePipe} from '@angular/common'; import {ChangeDetectionStrategy, Component, computed, effect, inject, input, output, ViewChild, ElementRef} from '@angular/core'; import {toSignal} from '@angular/core/rxjs-interop'; import {MatIconButton, MatButtonModule} from '@angular/material/button'; @@ -50,6 +50,7 @@ export type SpanNode = Span & { imports: [ AsyncPipe, DatePipe, + DecimalPipe, MatButtonModule, MatIconButton, MatIcon, @@ -331,6 +332,22 @@ export class EventTabComponent { return new Date(inMs).toLocaleString(); } + isNumber(value: any): boolean { + return typeof value === 'number'; + } + + isNumericValue(key: string, value: any): boolean { + if (typeof value === 'number') return true; + const detailKeys = [ + 'promptTokensDetails', + 'promptTokenDetails', + 'candidatesTokenDetails', + 'candidatesTokensDetails', + 'cacheTokensDetails', + ]; + return detailKeys.includes(key); + } + isObject(value: any): boolean { return value !== null && typeof value === 'object'; } From 389602fa395709c675c6a987de804b3111c12b69 Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Tue, 26 May 2026 14:17:43 -0700 Subject: [PATCH 27/30] feat: add button to refresh and load the latest session and move toolbar options into a menu --- src/app/components/chat/chat.component.html | 69 ++++++++++++++----- .../components/chat/chat.component.i18n.ts | 1 + src/app/components/chat/chat.component.scss | 10 +++ .../components/chat/chat.component.spec.ts | 56 +++++++++++++++ src/app/components/chat/chat.component.ts | 39 +++++++++++ 5 files changed, 156 insertions(+), 19 deletions(-) diff --git a/src/app/components/chat/chat.component.html b/src/app/components/chat/chat.component.html index e67f1e8b..404ff056 100644 --- a/src/app/components/chat/chat.component.html +++ b/src/app/components/chat/chat.component.html @@ -533,10 +533,23 @@
+ + @if (chatType() === 'eval-result') { } - @if (viewMode() !== 'traces') { - - } - @if ((isTokenStreamingEnabledObs | async) && canEditSession()) { - + + @if (hasBranchesOption) { + + } + @if (hasStreamingOption) { + + } + }
diff --git a/src/app/components/chat/chat.component.i18n.ts b/src/app/components/chat/chat.component.i18n.ts index 973adcd3..664fae49 100644 --- a/src/app/components/chat/chat.component.i18n.ts +++ b/src/app/components/chat/chat.component.i18n.ts @@ -22,6 +22,7 @@ import {InjectionToken} from '@angular/core'; */ export const CHAT_MESSAGES = { openPanelTooltip: 'Open panel', + retrieveLatestSessionTooltip: 'Retrieve latest session and show', evalCaseIdLabel: 'Eval Case ID', cancelButton: 'Cancel', saveButton: 'Save', diff --git a/src/app/components/chat/chat.component.scss b/src/app/components/chat/chat.component.scss index 2c8274d6..c4a60bad 100644 --- a/src/app/components/chat/chat.component.scss +++ b/src/app/components/chat/chat.component.scss @@ -1354,3 +1354,13 @@ app-canvas { color: var(--mat-sys-on-surface); } } + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.spinning { + animation: spin 1s linear infinite; +} + diff --git a/src/app/components/chat/chat.component.spec.ts b/src/app/components/chat/chat.component.spec.ts index e5d6c391..34d48920 100644 --- a/src/app/components/chat/chat.component.spec.ts +++ b/src/app/components/chat/chat.component.spec.ts @@ -1382,4 +1382,60 @@ describe('ChatComponent', () => { expect(combinedJson.data).toEqual([a2ui1, a2ui2]); }); }); + + describe('refreshLatestSession', () => { + beforeEach(() => { + mockAgentService.listAppsResponse.next([TEST_APP_1_NAME]); + component.appName = TEST_APP_1_NAME; + fixture.detectChanges(); + }); + + it('should do nothing if appName is not set', () => { + component.appName = ''; + (mockSessionService.listSessions as jasmine.Spy).calls.reset(); + + component.refreshLatestSession(); + + expect(mockSessionService.listSessions).not.toHaveBeenCalled(); + }); + + it('should list sessions, sort them descending by lastUpdateTime, and load the latest session', fakeAsync(() => { + const mockSessions: Session[] = [ + { id: 'session-old', lastUpdateTime: 1000 }, + { id: 'session-newest', lastUpdateTime: 3000 }, + { id: 'session-mid', lastUpdateTime: 2000 }, + ]; + + spyOn(component as any, 'loadSession').and.callThrough(); + mockSessionService.getSessionResponse.next({ id: 'session-newest', state: {}, events: [] }); + mockEventService.getTraceResponse.next([]); + + component.refreshLatestSession(); + + mockSessionService.listSessionsResponse.next({ items: mockSessions }); + tick(); + + expect(mockSessionService.listSessions).toHaveBeenCalledWith(USER_ID, TEST_APP_1_NAME); + expect((component as any).loadSession).toHaveBeenCalledWith('session-newest'); + expect(component.sessionId).toBe('session-newest'); + })); + + it('should show snackbar message when no sessions are found', fakeAsync(() => { + component.refreshLatestSession(); + + mockSessionService.listSessionsResponse.next({ items: [] }); + tick(); + + expect(mockSnackBar.open).toHaveBeenCalledWith('No sessions found for this app.', 'OK'); + })); + + it('should show snackbar message on list sessions API error', fakeAsync(() => { + component.refreshLatestSession(); + + mockSessionService.listSessionsResponse.error(new Error('API Error')); + tick(); + + expect(mockSnackBar.open).toHaveBeenCalledWith('Failed to refresh sessions.', 'OK'); + })); + }); }); diff --git a/src/app/components/chat/chat.component.ts b/src/app/components/chat/chat.component.ts index 46a5c50f..c5b11a32 100644 --- a/src/app/components/chat/chat.component.ts +++ b/src/app/components/chat/chat.component.ts @@ -1002,6 +1002,45 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { }); } + refreshLatestSession() { + if (!this.appName) { + return; + } + + this.uiStateService.setIsSessionLoading(true); + + this.sessionService.listSessions(this.userId, this.appName) + .pipe(first()) + .subscribe({ + next: (response) => { + if (response.items && response.items.length > 0) { + const sortedSessions = response.items.sort((a, b) => { + const timeA = Number(a.lastUpdateTime || 0); + const timeB = Number(b.lastUpdateTime || 0); + return timeB - timeA; + }); + + const latestSession = sortedSessions[0]; + if (latestSession.id) { + this.loadSession(latestSession.id); + } else { + this.uiStateService.setIsSessionLoading(false); + } + } else { + this.uiStateService.setIsSessionLoading(false); + this.openSnackBar('No sessions found for this app.', 'OK'); + } + + this.sessionTab?.refreshSession(); + }, + error: (err) => { + this.uiStateService.setIsSessionLoading(false); + this.openSnackBar('Failed to refresh sessions.', 'OK'); + console.error('Error listing sessions:', err); + } + }); + } + async handleChatInput(event: Event) { event.preventDefault(); if (!this.userInput.trim() && this.selectedFiles.length <= 0) return; From a68a2f94cae31edf614361ef380a4b60e4a900ae Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Tue, 26 May 2026 14:19:29 -0700 Subject: [PATCH 28/30] style: adjust padding and margin for chat action buttons and header container --- src/app/components/chat/chat.component.html | 3 +-- src/app/components/chat/chat.component.scss | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/components/chat/chat.component.html b/src/app/components/chat/chat.component.html index 404ff056..9af4d668 100644 --- a/src/app/components/chat/chat.component.html +++ b/src/app/components/chat/chat.component.html @@ -549,7 +549,7 @@ @if (chatType() === 'eval-result') { diff --git a/src/app/components/chat/chat.component.scss b/src/app/components/chat/chat.component.scss index c4a60bad..d0a5c07d 100644 --- a/src/app/components/chat/chat.component.scss +++ b/src/app/components/chat/chat.component.scss @@ -1102,7 +1102,7 @@ app-canvas { align-items: center; height: 48px; flex-shrink: 0; - padding: 0 20px; + padding: 0 8px 0 20px; background-color: var(--mat-sys-surface-container); border-bottom: 1px solid var(--mat-sys-outline-variant); From 060b328705a6dcaa917b0f05e616d4626db63c66 Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Tue, 26 May 2026 15:55:47 -0700 Subject: [PATCH 29/30] feat: implement manual scroll detection to disable automatic event selection in chat panel --- src/app/components/chat-panel/chat-panel.component.html | 2 +- src/app/components/chat-panel/chat-panel.component.ts | 6 ++++++ src/app/components/chat/chat.component.html | 4 ++++ src/app/components/chat/chat.component.ts | 4 ++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/components/chat-panel/chat-panel.component.html b/src/app/components/chat-panel/chat-panel.component.html index 2f5405f1..53b8127a 100644 --- a/src/app/components/chat-panel/chat-panel.component.html +++ b/src/app/components/chat-panel/chat-panel.component.html @@ -17,7 +17,7 @@ @let isSessionLoading = uiStateService.isSessionLoading() | async; @if (appName != "" && !isSessionLoading) { -
+
@if (showEvalSummary() && evalCaseResult()) { @let result = evalCaseResult();
diff --git a/src/app/components/chat-panel/chat-panel.component.ts b/src/app/components/chat-panel/chat-panel.component.ts index afaa091c..2222fea3 100644 --- a/src/app/components/chat-panel/chat-panel.component.ts +++ b/src/app/components/chat-panel/chat-panel.component.ts @@ -180,6 +180,7 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { @Output() readonly longRunningResponseComplete = new EventEmitter(); @Output() readonly toggleHideIntermediateEvents = new EventEmitter(); @Output() readonly toggleSse = new EventEmitter(); + @Output() readonly manualScroll = new EventEmitter(); @ViewChild('videoContainer', { read: ElementRef }) videoContainer!: ElementRef; @ViewChild('autoScroll') scrollContainer!: ElementRef; @@ -223,6 +224,11 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { protected readonly onScroll = new Subject(); protected readonly sanitizer = inject(SAFE_VALUES_SERVICE); + onManualScroll() { + this.scrollInterrupted = true; + this.manualScroll.emit(); + } + hideIntermediateEvents = input(false); invocationDisplayMap = input>(new Map()); evalCaseResult = input(null); diff --git a/src/app/components/chat/chat.component.html b/src/app/components/chat/chat.component.html index 9af4d668..fe10a3b9 100644 --- a/src/app/components/chat/chat.component.html +++ b/src/app/components/chat/chat.component.html @@ -640,6 +640,7 @@ [invocationDisplayMap]="invocationDisplayMap()" [viewMode]="viewMode()" [shouldShowEvent]="shouldShowEventFn" + (manualScroll)="onManualScroll()" > } @case ('eval-case') { @@ -754,6 +755,7 @@ [invocationDisplayMap]="invocationDisplayMap()" [viewMode]="viewMode()" [shouldShowEvent]="shouldShowEventFn" + (manualScroll)="onManualScroll()" >
@@ -806,6 +808,7 @@ [invocationDisplayMap]="invocationDisplayMap()" [viewMode]="viewMode()" [shouldShowEvent]="shouldShowEventFn" + (manualScroll)="onManualScroll()" >
@@ -827,6 +830,7 @@ [invocationDisplayMap]="invocationDisplayMap()" [viewMode]="viewMode()" [shouldShowEvent]="shouldShowEventFn" + (manualScroll)="onManualScroll()" > } } diff --git a/src/app/components/chat/chat.component.ts b/src/app/components/chat/chat.component.ts index c5b11a32..651dd5e6 100644 --- a/src/app/components/chat/chat.component.ts +++ b/src/app/components/chat/chat.component.ts @@ -3614,6 +3614,10 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { return edgeCounts; } + onManualScroll() { + this.autoSelectLatestEvent = false; + } + selectEvent(key: string, messageIndex?: number, isManual = true) { if (isManual) { this.autoSelectLatestEvent = false; From afbf6b710f6dcedf072315186dff306d5c89fbc8 Mon Sep 17 00:00:00 2001 From: Bo Yang Date: Tue, 26 May 2026 22:59:33 -0700 Subject: [PATCH 30/30] feat(chat): Make developer UI fully responsive and mobile friendly - Make app selector and session selector drawers take full screen width on mobile devices. - Limit ADK logo visibility in the toolbar to only hide when screen width is under 400px. - Prevent horizontal viewport overflow by setting chat-card min-width to 300px. - Stack side-by-side eval comparisons vertically and hide drag-resize handler on mobile. - Fix horizontal overflow and cut off in chat input on narrow viewports with border-box styling. - Compact chat inputs, chat messages padding, and content bubbles for narrow screens. --- .../chat-panel/chat-panel.component.scss | 21 +++++++ src/app/components/chat/chat.component.html | 4 +- src/app/components/chat/chat.component.scss | 63 ++++++++++++++----- src/app/components/chat/chat.component.ts | 17 +++++ .../content-bubble.component.scss | 11 ++++ .../event-row/event-row.component.scss | 18 ++++++ .../side-panel/side-panel.component.scss | 12 ++++ 7 files changed, 130 insertions(+), 16 deletions(-) diff --git a/src/app/components/chat-panel/chat-panel.component.scss b/src/app/components/chat-panel/chat-panel.component.scss index 8e8635ab..7a9c0361 100644 --- a/src/app/components/chat-panel/chat-panel.component.scss +++ b/src/app/components/chat-panel/chat-panel.component.scss @@ -172,6 +172,7 @@ margin: 0 auto; position: relative; transition: all 0.3s ease; + box-sizing: border-box; .chat-input-content-row { display: flex; @@ -456,3 +457,23 @@ button.send-message-btn { } } } + +@media (max-width: 768px) { + .chat-messages { + padding: 12px !important; + } + .chat-input { + width: 100% !important; + padding: 8px !important; + } + .chat-input-content-row { + gap: 8px !important; + } + .input-suffix-container { + gap: 4px !important; + margin-right: 4px !important; + } + button.input-prefix-menu-btn { + margin-left: 4px !important; + } +} diff --git a/src/app/components/chat/chat.component.html b/src/app/components/chat/chat.component.html index fe10a3b9..99cbfc08 100644 --- a/src/app/components/chat/chat.component.html +++ b/src/app/components/chat/chat.component.html @@ -96,7 +96,7 @@ @if (isNewSessionButtonEnabledObs | async) { @if (sessionId) {
- @if (uiEvents().length > 0) { + @if (uiEvents().length > 0 && !isMobile()) {