This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5075-62883b886837fc1d949e8c29b26b6e8153458d34 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 07be263ab901daad87e8e382c573234364107d6d Author: Matthew B. <[email protected]> AuthorDate: Fri May 22 00:01:05 2026 -0700 fix: filter __is_visualization__ from all result header sites (#5075) ### What changes were proposed in this PR? Operator result rows can carry two internal keys, `__row_index__` and `__is_visualization__`. Five sites in agent-service stripped these keys before computing column counts or rendering tables, but only one stripped both. The other four only stripped `__row_index__`, so `__is_visualization__` leaked into the rendered table body, making the body one column wider than the reported shape. This PR introduces a shared `INTERNAL_RESULT_KEYS` set and `getVisibleResultHeaders` helper in `tools utility.ts` and routes all five sites through it, eliminating the drift at the source. ### Any related issues, documentation, or discussions? Closes: #4724 ### How was this PR tested? Added a regression test in `result-formatting.test.ts` covering a row of `{ __is_visualization__: false, value: 1 }`, asserting the rendered body excludes the `__is_visualization__` column and matches the reported `(1, 1)` shape. Existing tests for the outer column count and visualization payload stripping continue to pass. ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.7 in compliance with ASF --------- Signed-off-by: Matthew B. <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> --- .../src/agent/tools/result-formatting.test.ts | 17 +++++++++++ agent-service/src/agent/tools/result-formatting.ts | 9 ++---- .../src/agent/tools/tools-utility.test.ts | 35 ++++++++++++++++++++++ agent-service/src/agent/tools/tools-utility.ts | 6 ++++ .../src/agent/tools/workflow-execution-tools.ts | 7 +++-- agent-service/src/server.ts | 6 ++-- 6 files changed, 67 insertions(+), 13 deletions(-) diff --git a/agent-service/src/agent/tools/result-formatting.test.ts b/agent-service/src/agent/tools/result-formatting.test.ts index 19a464c5e2..e6d1afdf2e 100644 --- a/agent-service/src/agent/tools/result-formatting.test.ts +++ b/agent-service/src/agent/tools/result-formatting.test.ts @@ -242,6 +242,23 @@ describe("formatOperatorResult - visualization rows", () => { expect(out).toContain("<keep/>"); expect(out).not.toContain("<skipped: visualization content>"); }); + + test("__is_visualization__ column is excluded from rendered table body and shape agrees", () => { + const out = formatOperatorResult( + "op1", + makeOpInfo({ + outputTuples: 1, + result: [{ __is_visualization__: false, value: 1 }], + }), + EMPTY_STATE + ); + const lines = out.split("\n"); + expect(out).toContain("Output table shape: (1, 1)"); + // Header line is the third line (after brief summary and shape line). + expect(lines[2]).toBe("\tvalue"); + expect(lines[3]).toBe("0\t1"); + expect(out).not.toContain("__is_visualization__"); + }); }); describe("jsonToTableFormat - cell coercion via formatOperatorResult", () => { diff --git a/agent-service/src/agent/tools/result-formatting.ts b/agent-service/src/agent/tools/result-formatting.ts index 9a11ba5085..5ed4aacc5d 100644 --- a/agent-service/src/agent/tools/result-formatting.ts +++ b/agent-service/src/agent/tools/result-formatting.ts @@ -19,7 +19,7 @@ import type { OperatorInfo } from "../../types/execution"; import type { WorkflowState } from "../workflow-state"; -import { formatExecuteOperatorResult } from "./tools-utility"; +import { formatExecuteOperatorResult, getVisibleResultHeaders } from "./tools-utility"; export function formatOperatorResult(operatorId: string, opInfo: OperatorInfo, workflowState: WorkflowState): string { if (opInfo.error) { @@ -31,10 +31,7 @@ export function formatOperatorResult(operatorId: string, opInfo: OperatorInfo, w } const jsonArray = opInfo.result as Record<string, any>[]; - const headers = - jsonArray.length > 0 - ? Object.keys(jsonArray[0]).filter(k => k !== "__row_index__" && k !== "__is_visualization__") - : []; + const headers = jsonArray.length > 0 ? getVisibleResultHeaders(jsonArray[0]) : []; const columns = headers.length; const isViz = jsonArray.length > 0 && jsonArray[0]["__is_visualization__"] === true; @@ -103,7 +100,7 @@ function jsonToTableFormat(jsonResult: Record<string, any>[]): string { if (!jsonResult || jsonResult.length === 0) return ""; const hasRowIndex = "__row_index__" in jsonResult[0]; - const headers = Object.keys(jsonResult[0]).filter(h => h !== "__row_index__"); + const headers = getVisibleResultHeaders(jsonResult[0]); if (headers.length === 0) return ""; const headerLine = "\t" + headers.join("\t"); diff --git a/agent-service/src/agent/tools/tools-utility.test.ts b/agent-service/src/agent/tools/tools-utility.test.ts index 94043f62f9..b505199709 100644 --- a/agent-service/src/agent/tools/tools-utility.test.ts +++ b/agent-service/src/agent/tools/tools-utility.test.ts @@ -25,8 +25,43 @@ import { formatModifyOperatorResult, formatExecuteOperatorResult, formatOperatorError, + getVisibleResultHeaders, } from "./tools-utility"; +describe("getVisibleResultHeaders", () => { + test("returns every key when no internal columns are present", () => { + expect(getVisibleResultHeaders({ a: 1, b: 2 })).toEqual(["a", "b"]); + }); + + test("strips __row_index__ from the result", () => { + expect(getVisibleResultHeaders({ __row_index__: 0, a: 1 })).toEqual(["a"]); + }); + + test("strips __is_visualization__ from the result", () => { + expect(getVisibleResultHeaders({ __is_visualization__: true, a: 1 })).toEqual(["a"]); + }); + + test("strips every known internal column at once", () => { + expect(getVisibleResultHeaders({ __row_index__: 0, __is_visualization__: true, a: 1, b: 2 })).toEqual(["a", "b"]); + }); + + test("preserves visible column order", () => { + expect(getVisibleResultHeaders({ z: 1, __row_index__: 0, a: 2, __is_visualization__: true, m: 3 })).toEqual([ + "z", + "a", + "m", + ]); + }); + + test("returns an empty array for an empty row", () => { + expect(getVisibleResultHeaders({})).toEqual([]); + }); + + test("returns an empty array when only internal columns are present", () => { + expect(getVisibleResultHeaders({ __row_index__: 0, __is_visualization__: true })).toEqual([]); + }); +}); + describe("createToolResult", () => { test("returns the message unchanged", () => { expect(createToolResult("ok")).toBe("ok"); diff --git a/agent-service/src/agent/tools/tools-utility.ts b/agent-service/src/agent/tools/tools-utility.ts index 3bc1a1d431..6c9ab004f6 100644 --- a/agent-service/src/agent/tools/tools-utility.ts +++ b/agent-service/src/agent/tools/tools-utility.ts @@ -17,6 +17,12 @@ * under the License. */ +export const INTERNAL_RESULT_KEYS: ReadonlySet<string> = new Set(["__row_index__", "__is_visualization__"]); + +export function getVisibleResultHeaders(row: Record<string, any>): string[] { + return Object.keys(row).filter(k => !INTERNAL_RESULT_KEYS.has(k)); +} + export function createToolResult(message: string): string { return message; } diff --git a/agent-service/src/agent/tools/workflow-execution-tools.ts b/agent-service/src/agent/tools/workflow-execution-tools.ts index 15fa81ff97..78c6cfa3d5 100644 --- a/agent-service/src/agent/tools/workflow-execution-tools.ts +++ b/agent-service/src/agent/tools/workflow-execution-tools.ts @@ -19,7 +19,7 @@ import { z } from "zod"; import { tool } from "ai"; -import { createErrorResult, formatExecuteOperatorResult } from "./tools-utility"; +import { createErrorResult, formatExecuteOperatorResult, getVisibleResultHeaders } from "./tools-utility"; import type { WorkflowState } from "../workflow-state"; import { getBackendConfig } from "../../api/backend-api"; import { env } from "../../config/env"; @@ -397,7 +397,8 @@ function jsonToTableFormat(jsonResult: Record<string, any>[]): string { if (!jsonResult || jsonResult.length === 0) return ""; const hasRowIndex = jsonResult.length > 0 && "__row_index__" in jsonResult[0]; - const headers = Object.keys(jsonResult[0]).filter(h => h !== "__row_index__"); + const headers = getVisibleResultHeaders(jsonResult[0]); + if (headers.length === 0) return ""; // Leading tab aligns headers with the index column (pandas __repr__ style). const headerLine = "\t" + headers.join("\t"); @@ -519,7 +520,7 @@ export async function executeOperatorAndFormat( } const jsonArray = opInfo.result as Record<string, any>[]; - const headers = jsonArray.length > 0 ? Object.keys(jsonArray[0]).filter(k => k !== "__row_index__") : []; + const headers = jsonArray.length > 0 ? getVisibleResultHeaders(jsonArray[0]) : []; const columns = headers.length; // Notify for every operator in the execution so upstream stats are also stored. diff --git a/agent-service/src/server.ts b/agent-service/src/server.ts index a31f9ede11..d5eeae82c9 100644 --- a/agent-service/src/server.ts +++ b/agent-service/src/server.ts @@ -21,6 +21,7 @@ import { Elysia, t } from "elysia"; import { cors } from "@elysiajs/cors"; import { createOpenAI } from "@ai-sdk/openai"; import { TexeraAgent } from "./agent/texera-agent"; +import { getVisibleResultHeaders } from "./agent/tools/tools-utility"; import { getBackendConfig } from "./api/backend-api"; import { extractUserFromToken, validateToken } from "./api/auth-api"; import { retrieveWorkflow } from "./api/workflow-api"; @@ -444,10 +445,7 @@ function getOperatorResultSummaries(agent: TexeraAgent): Record<string, Operator inputTuples: info.inputTuples, outputTuples: info.outputTuples, inputPortShapes: info.inputPortShapes, - outputColumns: - info.result && info.result.length > 0 - ? Object.keys(info.result[0]).filter(k => k !== "__row_index__").length - : undefined, + outputColumns: info.result && info.result.length > 0 ? getVisibleResultHeaders(info.result[0]).length : undefined, error: info.error, warnings: info.warnings, consoleLogCount: info.consoleLogs?.length,
