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:
parent
b1497ab3c4
commit
8c3d9c6ed3
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue