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