diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts index 7f7b8ad..2ac5055 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts @@ -45,6 +45,38 @@ describe('execution viewer runtime values', () => { })).toEqual({ approve: 'Candidate evidence' }); }); + it('keeps the numbers an event recorded readable, so a warning can be opened instead of guessed at', () => { + // The characters-over-budget in a pruning warning only lived in the API until now. + const [event] = buildVisibleExecutionLogs([{ + id: 'event-1', + timestamp: 1, + type: 'LLM_CONTEXT_PRUNED', + message: 'Still 6332 character(s) over budget', + details: { iteration: 4, totalCharsAfter: 66332, budgetChars: 60000 } + }]); + + expect(event.detailsText).toContain('"totalCharsAfter": 66332'); + expect(event.detailsText).toContain('"budgetChars": 60000'); + }); + + it('offers nothing to open when an event carries no details', () => { + // A button that opens an empty box is worse than no button. + const [noDetails] = buildVisibleExecutionLogs([{ id: 'a', timestamp: 1, type: 'STEP_STARTED' }]); + const [emptyDetails] = buildVisibleExecutionLogs([{ id: 'b', timestamp: 1, type: 'STEP_STARTED', details: {} }]); + + expect(noDetails.detailsText).toBeNull(); + expect(emptyDetails.detailsText).toBeNull(); + }); + + it('still shows something for a payload that will not serialise', () => { + const cyclic: Record = { tool: 'read_file' }; + cyclic['self'] = cyclic; + + const [event] = buildVisibleExecutionLogs([{ id: 'c', timestamp: 1, type: 'MCP_TOOL_CALL', details: cyclic }]); + + expect(event.detailsText).toBeTruthy(); + }); + it('surfaces inner subflow identifiers from bias experiment events', () => { const [event] = buildVisibleExecutionLogs([{ id: 'event-1', diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts index 394e04f..45fe4a0 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts @@ -58,6 +58,8 @@ export type ExecutionIntermediateInputGroup = { export type ExecutionLogEntryView = ExecutionEventLogEntry & { messageText: string; + /** The event's own details, formatted for reading, or null when it carries none worth opening. */ + detailsText: string | null; levelText: string; innerExecutionId: string | null; innerNodeId: string | null; @@ -245,6 +247,7 @@ export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): Execu return { ...entry, messageText: String(entry.message ?? '').trim() || fallbackExecutionLogMessage(entry), + detailsText: formatExecutionLogDetails(entry.details), levelText: String(entry.level ?? 'INFO').toUpperCase(), innerExecutionId: nonEmptyLogDetail(details['innerExecutionId']), innerNodeId: nonEmptyLogDetail(details['innerNodeId']), @@ -255,6 +258,30 @@ export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): Execu }); } +/** + * What an event carries beyond its sentence - iteration numbers, character counts, tool names. + * + *

Formatted here rather than in the template because the decision of what is worth opening + * belongs with the data: an event with no details, or an empty map, must not offer a button that + * opens nothing. + */ +export function formatExecutionLogDetails(details: unknown): string | null { + if (details == null) return null; + if (typeof details !== 'object') { + const text = String(details).trim(); + return text || null; + } + if (Array.isArray(details) ? details.length === 0 : Object.keys(details).length === 0) { + return null; + } + try { + return JSON.stringify(details, null, 2); + } catch { + // A cyclic or otherwise unserialisable payload is still worth something to a reader. + return String(details); + } +} + function nonEmptyLogDetail(value: unknown): string | null { const normalized = value == null ? '' : String(value).trim(); return normalized || null; diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.css b/src/app/shared/task-execution-viewer/task-execution-viewer.css index d245e0c..a4afd9a 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.css +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.css @@ -465,6 +465,40 @@ font-size: 11px; } +.execution-log-actions { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +/* Quiet until hovered: one of these sits on every row that carries details. */ +.execution-log-details-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #64748b; + cursor: pointer; +} + +.execution-log-details-btn:hover { + background: #e2e8f0; + color: #0f172a; +} + +.execution-log-details-btn mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; +} + .execution-log-level { display: inline-flex; align-items: center; diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index 08f74a7..4121e6d 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -484,9 +484,21 @@ - - {{ event.levelText }} - +

+ @if (event.detailsText) { + + } + + {{ event.levelText }} + +
} diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index c67a922..3244633 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -1559,6 +1559,35 @@ export class TaskExecutionViewerComponent implements OnDestroy { element.scrollTop = element.scrollHeight; } + /** + * Opens what an event recorded beyond its one-line message. + * + *

The numbers that explain a warning - how many characters were over budget, which tool was + * called, how long a step waited - were already reaching the browser and being dropped on the + * floor, leaving the API as the only way to read them. + */ + async openEventDetails(event: ExecutionLogEntryView, domEvent?: Event) { + domEvent?.preventDefault(); + domEvent?.stopPropagation(); + if (!event.detailsText) return; + + await this.settingsDialog.open({ + title: event.messageText, + previewOnly: true, + fields: [ + { + key: 'details', + label: event.type ?? 'Event details', + type: 'textarea', + readonly: true, + rows: 18, + copyable: true + } + ], + initial: { details: event.detailsText } + }); + } + logLevelClass(level: string | null | undefined): string { return _logLevelClass(level); }