Harden visualization input normalization and table mapping

This commit is contained in:
Dmitry Lyalin
2026-02-10 20:47:11 -05:00
parent e907822dd5
commit 7a9a003430
4 changed files with 202 additions and 3 deletions
@@ -128,6 +128,38 @@ describe('VisualizationResultDisplay', () => {
expect(frame).toContain('Showing truncated data (8 original items)');
});
it('renders table object rows with humanized columns without blank cells', () => {
const visualization: VisualizationDisplay = {
type: 'visualization',
kind: 'table',
title: 'Server Health Check',
data: {
columns: ['Server Name', 'CPU %', 'Memory GB', 'Status'],
rows: [
{
server_name: 'api-1',
cpu_percent: 62,
memoryGb: 8,
status: 'healthy',
},
],
},
meta: {
truncated: false,
originalItemCount: 1,
},
};
const { lastFrame } = render(
<VisualizationResultDisplay visualization={visualization} width={100} />,
);
const frame = lastFrame();
expect(frame).toContain('Server Name');
expect(frame).toContain('api-1');
expect(frame).toContain('healthy');
});
it('renders diagram visualization with UML-like nodes', () => {
const visualization: VisualizationDisplay = {
type: 'visualization',
@@ -137,6 +137,42 @@ function normalizeCell(value: unknown): PrimitiveCell {
return JSON.stringify(value);
}
function normalizeLookupKey(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
}
function getRecordValueByColumn(
record: Record<string, unknown>,
column: string,
): unknown {
if (Object.prototype.hasOwnProperty.call(record, column)) {
return record[column];
}
const normalizedColumn = normalizeLookupKey(column);
if (normalizedColumn.length === 0) {
return undefined;
}
const entries = Object.entries(record).map(([key, value]) => ({
key,
value,
normalized: normalizeLookupKey(key),
}));
const exact = entries.find((entry) => entry.normalized === normalizedColumn);
if (exact) {
return exact.value;
}
const partial = entries.find(
(entry) =>
entry.normalized.includes(normalizedColumn) ||
normalizedColumn.includes(entry.normalized),
);
return partial?.value;
}
function makeBar(value: number, max: number, width: number): string {
const safeMax = max <= 0 ? 1 : max;
const ratio = Math.max(0, Math.min(1, value / safeMax));
@@ -234,7 +270,9 @@ function asTable(data: Record<string, unknown>): {
const record = row;
const keys = columns.length > 0 ? columns : Object.keys(record);
return keys.map((key) => normalizeCell(record[key]));
return keys.map((key) =>
normalizeCell(getRecordValueByColumn(record, key)),
);
});
const inferredColumns =
@@ -206,6 +206,34 @@ describe('RenderVisualizationTool', () => {
});
});
it('maps table object rows when column labels are humanized', async () => {
const result = await tool.buildAndExecute(
{
visualizationKind: 'table',
data: {
columns: ['Server Name', 'CPU %', 'Memory GB', 'Status'],
rows: [
{
server_name: 'api-1',
cpu_percent: 62,
memoryGb: 8,
status: 'healthy',
},
],
},
},
signal,
);
expect(result.returnDisplay).toMatchObject({
type: 'visualization',
kind: 'table',
data: {
rows: [['api-1', 62, 8, 'healthy']],
},
});
});
it('renders diagram visualization', async () => {
const params: RenderVisualizationToolParams = {
visualizationKind: 'diagram',
@@ -257,6 +285,31 @@ describe('RenderVisualizationTool', () => {
});
});
it('accepts top-level diagram direction alias', async () => {
const result = await tool.buildAndExecute(
{
visualizationKind: 'diagram',
direction: 'TB',
data: {
nodes: [
{ id: 'start', label: 'Start' },
{ id: 'end', label: 'End' },
],
edges: [{ from: 'start', to: 'end' }],
},
},
signal,
);
expect(result.returnDisplay).toMatchObject({
type: 'visualization',
kind: 'diagram',
data: {
direction: 'TB',
},
});
});
it('converts legacy dashboard payloads into table', async () => {
const params: RenderVisualizationToolParams = {
visualizationKind: 'table',
@@ -38,6 +38,8 @@ export interface RenderVisualizationToolParams {
xLabel?: string;
yLabel?: string;
unit?: string;
direction?: 'LR' | 'TB' | string;
diagramKind?: 'architecture' | 'flowchart' | string;
data: unknown;
sourceContext?: string;
sort?: SortMode;
@@ -129,6 +131,44 @@ function normalizeCell(value: unknown): PrimitiveCell {
return JSON.stringify(value);
}
function normalizeLookupKey(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
}
function getRecordValueByColumn(
record: Record<string, unknown>,
column: string,
): unknown {
if (Object.prototype.hasOwnProperty.call(record, column)) {
return record[column];
}
const normalizedColumn = normalizeLookupKey(column);
if (normalizedColumn.length === 0) {
return undefined;
}
const normalizedEntries = Object.entries(record).map(([key, value]) => ({
key,
value,
normalized: normalizeLookupKey(key),
}));
const exactNormalized = normalizedEntries.find(
(entry) => entry.normalized === normalizedColumn,
);
if (exactNormalized) {
return exactNormalized.value;
}
const includesNormalized = normalizedEntries.find(
(entry) =>
entry.normalized.includes(normalizedColumn) ||
normalizedColumn.includes(entry.normalized),
);
return includesNormalized?.value;
}
function applySort(
points: VisualizationPoint[],
sort: SortMode,
@@ -805,7 +845,9 @@ function normalizeTable(
if (columns.length === 0) {
columns = Object.keys(rawRow);
}
return columns.map((column) => normalizeCell(rawRow[column]));
return columns.map((column) =>
normalizeCell(getRecordValueByColumn(rawRow, column)),
);
}
throw new Error(`table row ${rowIndex} must be an array or object.`);
@@ -1090,9 +1132,33 @@ class RenderVisualizationToolInvocation extends BaseToolInvocation<
Math.min(this.params.maxItems ?? DEFAULT_MAX_ITEMS, MAX_ALLOWED_ITEMS),
);
const toolData = (() => {
const data = ensureRecord(this.params.data, '`data` must be an object.');
if (this.params.visualizationKind !== 'diagram') {
return data;
}
const diagramData: Record<string, unknown> = { ...data };
if (
diagramData['direction'] === undefined &&
typeof this.params.direction === 'string' &&
this.params.direction.trim().length > 0
) {
diagramData['direction'] = this.params.direction;
}
if (
diagramData['diagramKind'] === undefined &&
typeof this.params.diagramKind === 'string' &&
this.params.diagramKind.trim().length > 0
) {
diagramData['diagramKind'] = this.params.diagramKind;
}
return diagramData;
})();
const normalized = normalizeByKind(
this.params.visualizationKind,
this.params.data,
toolData,
sort,
maxItems,
);
@@ -1199,6 +1265,16 @@ export class RenderVisualizationTool extends BaseDeclarativeTool<
type: 'string',
description: 'Optional unit label for values.',
},
direction: {
type: 'string',
description:
'Optional top-level direction alias for diagram calls; copied into data.direction when provided.',
},
diagramKind: {
type: 'string',
description:
'Optional top-level diagram kind alias for diagram calls; copied into data.diagramKind when provided.',
},
data: {
type: 'object',
description: