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-7505-42d08a3701cd06542bcfa92728116302723b2536 in repository https://gitbox.apache.org/repos/asf/texera.git
commit a61e1ee4a8b08f196cf509171d6372f7ab5895b8 Author: Xinyuan Lin <[email protected]> AuthorDate: Mon Aug 10 09:43:43 2026 -0700 test(agent-service): pin the context serializer and the execution tools (#7505) ### What changes were proposed in this PR? `context-utils.ts` builds the prompt the agent reasons over and `workflow-execution-tools.ts` turns an execution result into the table it reads back. Both looked reasonably covered and were not: `bun` credits a whole function body once entered, so blocks inside `jsonToTableFormat` and `executeOperatorAndFormat` counted as covered while nothing asserted them. Adds 15 tests across the two existing specs (no new spec files — one spec per source class, both already existed). | File | Before | After | |---|---|---| | `context-utils.ts` | 60.00% funcs / 77.83% lines | **100% / 100%** | | `workflow-execution-tools.ts` | 58.97% funcs / 91.24% lines | 85.71% / 95.16% | **Ten of the killed mutations target lines `bun` already called covered** — the DAG topological ordering and its target-rank tie-break, port-ordinal mapping, the `NULL`/`null`/`undefined`/object cell rendering, the row-gap ellipsis, the leading-tab header, and the schema-violation messages. The file percentage barely moves for those; the pinning is the point. ### Verification 45 mutations were applied to the production files and reverted, each revert confirmed with `git diff --quiet` before the next. All 45 turned the suite red. An adversarial re-check then found **two assertions that were vacuous anyway**, which is the part worth reporting: | Survivor | Why it passed | Fix | |---|---|---| | `Math.ceil` → `Math.round` on the execution timeout | the fixture was `4500 ms`, and `ceil(4.5) == round(4.5) == 5`, so the assertion commented "rounds up" proved nothing | fixture changed to `4200 ms`, which separates ceil (5) from round and floor (4) | | `getConfig()` hoisted out of the `execute` closure | the test invoked the tool once, so `toHaveBeenCalledTimes(1)` holds whether the config is resolved per invocation or captured once at construction | invoke twice with the workflow id changing in between, and assert the second request URL reflects the second config | Both mutations are now red, and so is `Math.floor`. Production diff empty. ### Deliberately not included Three regions of `workflow-execution-tools.ts` are left uncovered because they are dead, confirmed by inserting `throw new Error(...)` at the top of each and running the whole 273-test module — all still passed, so nothing reaches them: - `formatWorkflowValidationErrors` (169–178) — no call site. - lines 221–226 and 229–233. Testing them would cement code that should be deleted instead. No production file is touched. ### Any related issues, documentation, discussions? Closes #7504 ### How was this PR tested? ``` bun test ``` ``` 273 pass 0 fail ``` 15 new on top of the existing 258. `bun run typecheck` and `bun run format:check` both pass. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) Co-authored-by: Meng Wang <[email protected]> --- .../agent/tools/workflow-execution-tools.spec.ts | 240 ++++++++++++++++++++- agent-service/src/agent/util/context-utils.spec.ts | 145 ++++++++++++- 2 files changed, 380 insertions(+), 5 deletions(-) diff --git a/agent-service/src/agent/tools/workflow-execution-tools.spec.ts b/agent-service/src/agent/tools/workflow-execution-tools.spec.ts index f3623e2a4d..36313f181a 100644 --- a/agent-service/src/agent/tools/workflow-execution-tools.spec.ts +++ b/agent-service/src/agent/tools/workflow-execution-tools.spec.ts @@ -18,13 +18,17 @@ */ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; -import { executeOperatorAndFormat, type ExecutionConfig } from "./workflow-execution-tools"; +import { createExecuteOperatorTool, executeOperatorAndFormat, type ExecutionConfig } from "./workflow-execution-tools"; import { WorkflowState } from "../workflow-state"; import { WorkflowSystemMetadata } from "../util/workflow-system-metadata"; -import type { OperatorPredicate, PortDescription } from "../../types/workflow"; +import type { OperatorLink, OperatorPredicate, PortDescription } from "../../types/workflow"; import type { OperatorInfo, SyncExecutionResult } from "../../types/execution"; -function makeOperator(id: string, inputPorts: PortDescription[] = []): OperatorPredicate { +function makeOperator( + id: string, + inputPorts: PortDescription[] = [], + overrides: Partial<OperatorPredicate> = {} +): OperatorPredicate { return { operatorID: id, operatorType: "TestOp", @@ -33,6 +37,15 @@ function makeOperator(id: string, inputPorts: PortDescription[] = []): OperatorP inputPorts, outputPorts: [], showAdvanced: false, + ...overrides, + }; +} + +function makeLink(source: string, sourcePort: string, target: string, targetPort: string): OperatorLink { + return { + linkID: `${source}.${sourcePort}->${target}.${targetPort}`, + source: { operatorID: source, portID: sourcePort }, + target: { operatorID: target, portID: targetPort }, }; } @@ -42,6 +55,11 @@ function stateWith(...operators: OperatorPredicate[]): WorkflowState { return state; } +// The JSON body of the nth fetch the code under test issued. +function requestBody(spy: ReturnType<typeof spyOn>, callIndex = 0): any { + return JSON.parse((spy.mock.calls[callIndex][1] as RequestInit).body as string); +} + function cfg(overrides: Partial<ExecutionConfig> = {}): ExecutionConfig { return { userToken: "tok", workflowId: 1, ...overrides }; } @@ -90,6 +108,30 @@ describe("executeOperatorAndFormat — guards & validation", () => { expect(result).toBe("[ERROR] Operator op1:\n - inputs: input-0 requires at least 1 input, has 0."); expect(fetchSpy).not.toHaveBeenCalled(); }); + + test("merges schema and connection violations into one blocking message", async () => { + validateSpy.mockReturnValue({ isValid: false, messages: { limit: "must be a number" } }); + const state = stateWith(makeOperator("op1", [{ portID: "input-0" }])); + + const result = await executeOperatorAndFormat(state, cfg(), "op1"); + + expect(result).toBe( + "[ERROR] Operator op1:\n - limit: must be a number\n - inputs: input-0 requires at least 1 input, has 0." + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("rejects a single-input port that has more than one incoming link, naming it by display name", async () => { + const dst = makeOperator("dst", [{ portID: "input-0", displayName: "Left", disallowMultiInputs: true }]); + const state = stateWith(makeOperator("a"), makeOperator("b"), dst); + state.addLink(makeLink("a", "output-0", "dst", "input-0")); + state.addLink(makeLink("b", "output-0", "dst", "input-0")); + + const result = await executeOperatorAndFormat(state, cfg(), "dst"); + + expect(result).toBe("[ERROR] Operator dst:\n - inputs: Left requires 1 input, has 2."); + expect(fetchSpy).not.toHaveBeenCalled(); + }); }); describe("executeOperatorAndFormat — execution-level failures", () => { @@ -277,6 +319,198 @@ describe("executeOperatorAndFormat — operator result handling", () => { }); }); +describe("executeOperatorAndFormat — request construction", () => { + test("sends the upstream sub-DAG with port ordinals resolved from each operator's port list", async () => { + const src = makeOperator("src", [], { + operatorProperties: { limit: 3 }, + outputPorts: [{ portID: "output-0" }, { portID: "output-1" }], + }); + const dst = makeOperator("dst", [{ portID: "input-0" }, { portID: "input-1" }]); + const state = stateWith(src, dst); + state.addLink(makeLink("src", "output-1", "dst", "input-0")); + // An unknown source port falls back to ordinal 0 rather than -1. + state.addLink(makeLink("src", "output-9", "dst", "input-1")); + resolveFetch(fetchSpy, { + success: true, + state: "Completed", + operators: { + dst: { state: "Completed", inputTuples: 0, outputTuples: 1, resultMode: "table", result: [{ a: 1 }] }, + }, + }); + + await executeOperatorAndFormat(state, cfg({ workflowId: 7, computingUnitId: 3, executionTimeoutMs: 4200 }), "dst"); + + expect(String(fetchSpy.mock.calls[0][0])).toBe("http://localhost:8085/api/execution/7/3/run"); + expect((fetchSpy.mock.calls[0][1] as RequestInit).headers).toMatchObject({ Authorization: "Bearer tok" }); + + const body = requestBody(fetchSpy); + expect(body.executionName).toBe("agent-execution"); + // 4200 rather than a round 4500: with 4500 the assertion holds for ceil, round *and* + // floor-plus-one, so it would not notice the rounding being changed. 4200 separates them. + expect(body.timeoutSeconds).toBe(5); + expect(body.targetOperatorIds).toEqual(["dst"]); + expect(body.logicalPlan.opsToViewResult).toEqual(["dst"]); + expect(body.logicalPlan.links).toEqual([ + { + fromOpId: "src", + fromPortId: { id: 1, internal: false }, + toOpId: "dst", + toPortId: { id: 0, internal: false }, + }, + { + fromOpId: "src", + fromPortId: { id: 0, internal: false }, + toOpId: "dst", + toPortId: { id: 1, internal: false }, + }, + ]); + // Operator properties are flattened into the wire operator alongside its ports. + expect(body.logicalPlan.operators).toContainEqual( + expect.objectContaining({ operatorID: "src", operatorType: "TestOp", limit: 3 }) + ); + }); +}); + +describe("executeOperatorAndFormat — result rendering", () => { + test("labels input shapes with the upstream operator id, ordered by port index", async () => { + const dst = makeOperator("dst", [{ portID: "input-0" }, { portID: "input-1" }]); + const state = stateWith(makeOperator("a"), makeOperator("b"), dst); + state.addLink(makeLink("a", "output-0", "dst", "input-0")); + state.addLink(makeLink("b", "output-0", "dst", "input-1")); + resolveFetch(fetchSpy, { + success: true, + state: "Completed", + operators: { + dst: { + state: "Completed", + inputTuples: 0, + outputTuples: 99, + resultMode: "table", + totalRowCount: 4, + // Deliberately out of order to pin the sort by port index. + inputPortShapes: [ + { portIndex: 1, rows: 20, columns: 2 }, + { portIndex: 0, rows: 5, columns: 3 }, + ], + result: [{ x: 1 }], + }, + }, + }); + + const result = await executeOperatorAndFormat(state, cfg(), "dst"); + + expect(result).toContain("Input operator(table shape): a(5, 3), b(20, 2)"); + expect(result).toContain("Output table shape: (4, 1)"); // totalRowCount wins over outputTuples + }); + + test("falls back to outputTuples for the row count and appends operator warnings", async () => { + const state = stateWith(makeOperator("op1")); + resolveFetch(fetchSpy, { + success: true, + state: "Completed", + operators: { + op1: { + state: "Completed", + inputTuples: 0, + outputTuples: 3, + resultMode: "table", + warnings: ["result truncated"], + result: [{ x: 1 }], + }, + }, + }); + + const result = await executeOperatorAndFormat(state, cfg(), "op1"); + + expect(result.split("\n").slice(0, 3)).toEqual([ + "Executed operator op1", + "Output table shape: (3, 1)", + "result truncated", + ]); + }); + + test("renders backend row indices, inserting an ellipsis row where they skip", async () => { + const state = stateWith(makeOperator("op1")); + resolveFetch(fetchSpy, { + success: true, + state: "Completed", + operators: { + op1: { + state: "Completed", + inputTuples: 0, + outputTuples: 4, + resultMode: "table", + totalRowCount: 4, + result: [ + { __row_index__: 0, a: 1, b: "x" }, + { __row_index__: 1, a: null, b: "NULL" }, + { __row_index__: 10, a: true, b: "has\ttab\nand newline" }, + { __row_index__: 11, a: { k: 1 }, b: undefined }, + ], + }, + }, + }); + + const result = await executeOperatorAndFormat(state, cfg(), "op1"); + + expect(result.split("\n").slice(1)).toEqual([ + "Output table shape: (4, 2)", // __row_index__ is internal and not counted as a column + "\ta\tb", + "0\t1\tx", + "1\tNaN\tNaN", + "...\t...\t...", + "10\ttrue\thas\\ttab\\nand newline", + '11\t{"k":1}\t', + ]); + }); + + test("emits only the summary and shape line when the operator produced zero rows", async () => { + const state = stateWith(makeOperator("op1")); + resolveFetch(fetchSpy, { + success: true, + state: "Completed", + operators: { + op1: { state: "Completed", inputTuples: 0, outputTuples: 0, resultMode: "table", result: [] }, + }, + }); + + const result = await executeOperatorAndFormat(state, cfg(), "op1"); + + expect(result).toBe("Executed operator op1\nOutput table shape: (0, 0)"); + }); +}); + +describe("createExecuteOperatorTool", () => { + test("resolves the config per invocation and forwards the operator id and onResult hook", async () => { + const state = stateWith(makeOperator("op1")); + resolveFetch(fetchSpy, { + success: true, + state: "Completed", + operators: { + op1: { state: "Completed", inputTuples: 0, outputTuples: 1, resultMode: "table", result: [{ a: 1 }] }, + }, + }); + const onResult = mock((_id: string, _info: OperatorInfo) => {}); + let workflowId = 42; + const getConfig = mock(() => cfg({ workflowId })); + + const executeTool = createExecuteOperatorTool(state, getConfig, onResult); + const output = await executeTool.execute!({ operatorId: "op1" }, {} as any); + // Invoked twice, with the workflow id changing in between. One invocation cannot tell + // per-invocation resolution from a config captured once when the tool was built: the call + // count is 1 either way. The second URL is what proves the later config is the one used. + workflowId = 43; + await executeTool.execute!({ operatorId: "op1" }, {} as any); + + expect(getConfig).toHaveBeenCalledTimes(2); + expect(output).toContain("Executed operator op1"); + expect(String(fetchSpy.mock.calls[0][0])).toContain("/api/execution/42/0/run"); + expect(String(fetchSpy.mock.calls[1][0])).toContain("/api/execution/43/0/run"); + expect(onResult).toHaveBeenCalledTimes(2); + expect(onResult.mock.calls[0][0]).toBe("op1"); + }); +}); + describe("executeOperatorAndFormat — cancellation", () => { test("re-throws AbortError instead of formatting it as a result", async () => { const state = stateWith(makeOperator("op1")); diff --git a/agent-service/src/agent/util/context-utils.spec.ts b/agent-service/src/agent/util/context-utils.spec.ts index 84af9df3bb..9ad90492d1 100644 --- a/agent-service/src/agent/util/context-utils.spec.ts +++ b/agent-service/src/agent/util/context-utils.spec.ts @@ -22,7 +22,8 @@ import type { ModelMessage } from "ai"; import { assembleContext } from "./context-utils"; import { WorkflowState } from "../workflow-state"; import type { ReActStep } from "../../types/agent"; -import type { OperatorPredicate } from "../../types/workflow"; +import type { OperatorLink, OperatorPredicate } from "../../types/workflow"; +import type { WorkflowCompilationResponse } from "../../api/compile-api"; function step(messageId: string, role: "user" | "agent", stepId: number, content: string, isEnd: boolean): ReActStep { return { @@ -37,7 +38,7 @@ function step(messageId: string, role: "user" | "agent", stepId: number, content }; } -function makeOperator(id: string): OperatorPredicate { +function makeOperator(id: string, overrides: Partial<OperatorPredicate> = {}): OperatorPredicate { return { operatorID: id, operatorType: "TestOp", @@ -46,6 +47,15 @@ function makeOperator(id: string): OperatorPredicate { inputPorts: [], outputPorts: [], showAdvanced: false, + ...overrides, + }; +} + +function makeLink(source: string, sourcePort: string, target: string, targetPort: string): OperatorLink { + return { + linkID: `${source}.${sourcePort}->${target}.${targetPort}`, + source: { operatorID: source, portID: sourcePort }, + target: { operatorID: target, portID: targetPort }, }; } @@ -100,4 +110,135 @@ describe("assembleContext", () => { expect(content).toContain("### Turn 1"); expect(content).toContain("### Turn 2"); }); + + test("labels each tool call with the status of the result at the same index", () => { + const agentStep: ReActStep = { + ...step("m1", "agent", 1, "acting", true), + toolCalls: [ + { toolName: "addOperator", toolCallId: "c1", input: {} }, + { toolName: "executeOperator", toolCallId: "c2", input: {} }, + { toolName: "deleteOperator", toolCallId: "c3", input: {} }, + ], + // Deliberately shorter than toolCalls: a call still awaiting its result + // must not be reported as failed. + toolResults: [ + { toolCallId: "c1", output: "ok" }, + { toolCallId: "c2", output: "bad", isError: true }, + ], + }; + const content = contentOf( + assembleContext([step("m1", "user", 0, "req", true), agentStep], new WorkflowState(), new Map()) + ); + expect(content).toContain("- addOperator (succeeded)"); + expect(content).toContain("- executeOperator (failed)"); + expect(content).toContain("- deleteOperator (succeeded)"); + }); +}); + +describe("assembleContext — dataflow serialization", () => { + test("orders operators and links topologically rather than by insertion order", () => { + const workflowState = new WorkflowState(); + // Inserted leaf-first, so insertion order is the reverse of the dataflow order. + workflowState.addOperator(makeOperator("c")); + workflowState.addOperator(makeOperator("b")); + workflowState.addOperator(makeOperator("a")); + workflowState.addLink(makeLink("b", "output-0", "c", "input-0")); + workflowState.addLink(makeLink("a", "output-0", "c", "input-1")); + workflowState.addLink(makeLink("a", "output-0", "b", "input-0")); + + const content = contentOf(assembleContext([], workflowState, new Map())); + + expect(content.indexOf("### Operator `a`")).toBeLessThan(content.indexOf("### Operator `b`")); + expect(content.indexOf("### Operator `b`")).toBeLessThan(content.indexOf("### Operator `c`")); + // Links are sorted by source rank, then by target rank — a → b precedes a → c. + const linkLines = content.slice(content.indexOf("## Links")).split("\n").slice(1); + expect(linkLines).toEqual(["- a → b", "- a → c", "- b → c"]); + }); + + test("renders input schemas by port ordinal and falls through to the first defined output schema", () => { + const workflowState = new WorkflowState(); + workflowState.addOperator(makeOperator("src", { outputPorts: [{ portID: "output-0" }] })); + workflowState.addOperator(makeOperator("dst", { inputPorts: [{ portID: "input-0" }] })); + workflowState.addLink(makeLink("src", "output-0", "dst", "input-0")); + + const compilationResult: WorkflowCompilationResponse = { + operatorOutputSchemas: { + src: { + "0_false": [ + { attributeName: "a", attributeType: "string" }, + { attributeName: "n", attributeType: "integer" }, + ], + }, + // Port 0 carries no schema, so the output line must come from port 1. + dst: { "0_false": undefined, "1_false": [{ attributeName: "flag", attributeType: "boolean" }] }, + }, + operatorErrors: {}, + }; + + const content = contentOf(assembleContext([], workflowState, new Map(), false, compilationResult)); + + expect(content).toContain("Input Schema (port 0): [a: string, n: integer]"); + expect(content).toContain("Output Schema: [a: string, n: integer]"); + expect(content).toContain("Output Schema: [flag: boolean]"); + // The wire port id "0_false" is trimmed down to the bare ordinal. + expect(content).not.toContain("port 0_false"); + }); + + test("renders non-empty properties and JSON-encodes non-string values", () => { + const workflowState = new WorkflowState(); + workflowState.addOperator( + makeOperator("op1", { + operatorProperties: { name: "alice", limit: 5, tags: ["x", "y"], blank: "", missing: null, absent: undefined }, + }) + ); + + const content = contentOf(assembleContext([], workflowState, new Map())); + + expect(content).toContain("Summary: op1"); // no display name -> falls back to the id + expect(content).toContain(" name: alice"); + expect(content).toContain(" limit: 5"); + expect(content).toContain(' tags: ["x","y"]'); + expect(content).not.toContain("blank"); + expect(content).not.toContain("missing"); + expect(content).not.toContain("absent"); + }); + + test("omits properties under redaction unless the operator's result reports an error", () => { + const workflowState = new WorkflowState(); + workflowState.addOperator(makeOperator("op1", { operatorProperties: { secret: "s3cr3t" } })); + + const redacted = contentOf(assembleContext([], workflowState, new Map(), true)); + expect(redacted).toContain("(TestOp, not-executed)"); + expect(redacted).not.toContain("Properties:"); + + // A failing operator keeps its properties even when redacting, so the model + // can see what caused the failure. + const failed = contentOf(assembleContext([], workflowState, new Map([["op1", "[ERROR] boom"]]), true)); + expect(failed).toContain("(TestOp, failed)"); + expect(failed).toContain(" secret: s3cr3t"); + }); + + test("marks an operator executed and indents every line of its result", () => { + const workflowState = new WorkflowState(); + workflowState.addOperator(makeOperator("op1", { customDisplayName: "My Filter" })); + + const content = contentOf(assembleContext([], workflowState, new Map([["op1", "header\nrow1"]]))); + + expect(content).toContain("### Operator `op1` (TestOp, executed)"); + expect(content).toContain("Summary: My Filter"); + expect(content).toContain("Result:\n header\n row1"); + }); + + test("surfaces a per-operator compilation error", () => { + const workflowState = new WorkflowState(); + workflowState.addOperator(makeOperator("op1")); + const compilationResult: WorkflowCompilationResponse = { + operatorOutputSchemas: {}, + operatorErrors: { op1: { type: "CompilationError", message: "attribute x not found" } }, + }; + + const content = contentOf(assembleContext([], workflowState, new Map(), false, compilationResult)); + + expect(content).toContain("Compilation Error: attribute x not found"); + }); });
