Let an event's own numbers be opened from the log

The log showed each event's sentence and threw away everything behind it.
A pruning warning saying "6332 characters over budget" could not tell you
what the total actually was, a tool call could not say which tool: those
numbers reached the browser and were dropped, leaving the API as the only
way to read them.

Rows that carry details now offer an eye, which opens them read-only.
Rows that carry none offer nothing - a button opening an empty box is
worse than no button - so the decision of what is worth opening lives in
the view model, where it can be tested, rather than in the template.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-21 11:07:38 +02:00
parent b1497ab3c4
commit 8c3d9c6ed3
5 changed files with 137 additions and 3 deletions

View File

@ -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<string, unknown> = { 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',

View File

@ -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.
*
* <p>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;

View File

@ -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;

View File

@ -484,9 +484,21 @@
</div>
</div>
</div>
<span class="execution-log-level" [ngClass]="logLevelClass(event.level)">
{{ event.levelText }}
</span>
<div class="execution-log-actions">
@if (event.detailsText) {
<button
type="button"
class="execution-log-details-btn"
matTooltip="View event details"
[attr.aria-label]="'View details of ' + event.messageText"
(click)="openEventDetails(event, $event)">
<mat-icon fontIcon="visibility"></mat-icon>
</button>
}
<span class="execution-log-level" [ngClass]="logLevelClass(event.level)">
{{ event.levelText }}
</span>
</div>
</div>
</div>
}

View File

@ -1559,6 +1559,35 @@ export class TaskExecutionViewerComponent implements OnDestroy {
element.scrollTop = element.scrollHeight;
}
/**
* Opens what an event recorded beyond its one-line message.
*
* <p>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);
}