This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 561cd0ef1f test(agent-service): drive sendMessage with a stand-in
language model (#7487)
561cd0ef1f is described below
commit 561cd0ef1fb85da3f2f59fe846947d93f5c14262
Author: Xinyuan Lin <[email protected]>
AuthorDate: Mon Aug 10 22:00:57 2026 -0700
test(agent-service): drive sendMessage with a stand-in language model
(#7487)
### What changes were proposed in this PR?
`sendMessage` held 238 of the 292 uncovered lines in `texera-agent.ts`,
and the existing spec stops
at the model boundary. Nothing in there actually needs the network:
`ai/test` ships a
`MockLanguageModelV4` satisfying the same `LanguageModel` type the
constructor already takes, so the
loop runs in-process. `fetch` is spied on as a tripwire, and the
no-delegate tests assert that not a
single call escapes.
Adds 25 tests in two blocks:
| Block | Covers |
|---|---|
| `sendMessage` | branch bookkeeping and ancestor path, per-step and
summed usage, the assembled context replacing the raw message, tool
projection and rolling before/after snapshots, the `maxSteps` cap, turn
chaining, an abandoned branch staying invisible to the model, DAG
compilation feeding schemas and cached results into the prompt |
| `sendMessage` failures | a thrown model recorded as an error step, a
non-`Error` throw stringified, a failed turn staying on the branch,
cancellation reported as stopped, an `AbortError`-named provider error
read as a user stop, `stop()` mid-run preventing the next call,
`GENERATING` for the duration |
| `delegate mode` | the one-time backend refresh and a failed refresh
being swallowed, auto-execution after `modifyOperator` and where its
result is keyed, the two guards that suppress it,
`buildExecutionConfig`, and the debounced auto-persist plus its failure
path |
`texera-agent.ts` goes from **51.01% to 99.82% lines** (80.43% → 98.21%
funcs).
### On what these tests actually pin
Line coverage overstates this, and it is worth being precise. `bun`
credits a whole function body
once it is entered, so the first test alone takes the uncovered count
from 292 to 27. Most of the
remaining 24 buy no additional lines — they exist because each kills a
mutation nothing else kills.
They are mutation guards, not coverage.
The matrix ran 44 mutations against a pristine source with a
checkout-and-verify between each. Six
were re-run independently after the tests were merged into the existing
spec:
| Mutation | Expected | Result |
|---|---|---|
| a tool call without `operatorId` still auto-executes | red | red |
| an `[ERROR]` tool result no longer suppresses the follow-up run | red
| red |
| the `EXECUTE_AFTER_TOOLS` filter dropped | red | red |
| input tokens not mapped | red | red |
| `totalUsage` preferred over `usage` | **survives** | survives |
| the `content: text \|\| ""` fallback | **survives** | survives |
The last two are listed deliberately. They survive because they are
unobservable in `[email protected]` —
`totalUsage` and `usage` are the same object, and the SDK already hands
`""` to a text-less step.
No test claims to pin them, and no test was written to cement them.
Five further fragments are line-covered but not behaviourally pinned,
for the same reason:
`lastPreparedMessages = undefined` (re-assigned before every step),
`isError: !!(tr.output)?.error`
(no tool ever returns an object), the `?? finalUsage?.promptTokens` /
`?? completionTokens` arms
(v4-era key names that no longer exist), and the delegate guard at
408–410 (masked by the catch
below it).
### Deliberately not included
- **`getStepsById` (line 261)** — the one line left uncovered. No call
site anywhere in the repo;
`server.ts` uses `getReActSteps` / `getAllSteps` /
`getVisibleReActSteps`. It also hands out the
live private `Map` by reference. Deleting it beats testing it, and that
belongs in its own change.
- **`currentMessageId`** — five writes, zero reads. An assertion on it
would cement dead state.
- **`maxSteps: 0`** — writing that test hangs the suite rather than
failing it. Filed as #7484.
Two defects surfaced while writing these and are filed rather than fixed
here, since this PR touches no production code: #7484 (a `maxSteps` of 0
silently disables the step cap) and #7485 (a falsy throw from the model
makes `sendMessage` reject instead of reporting an error step).
No production file is touched.
### Any related issues, documentation, discussions?
Closes #7486
### How was this PR tested?
```
bun test
```
```
253 pass
0 fail
```
25 new on top of the existing 228. `bun run typecheck` passes.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)
---
agent-service/src/agent/texera-agent.spec.ts | 808 ++++++++++++++++++++++++++-
1 file changed, 807 insertions(+), 1 deletion(-)
diff --git a/agent-service/src/agent/texera-agent.spec.ts
b/agent-service/src/agent/texera-agent.spec.ts
index 27aa37b813..416fee5444 100644
--- a/agent-service/src/agent/texera-agent.spec.ts
+++ b/agent-service/src/agent/texera-agent.spec.ts
@@ -17,9 +17,11 @@
* under the License.
*/
-import { beforeEach, describe, expect, test } from "bun:test";
+import { afterEach, beforeEach, describe, expect, spyOn, test } from
"bun:test";
import { TexeraAgent } from "./texera-agent";
import { AgentState, INITIAL_STEP_ID, type ReActStep } from "../types/agent";
+import { MockLanguageModelV4 } from "ai/test";
+import { WorkflowSystemMetadata } from "./util/workflow-system-metadata";
/**
* These tests cover the agent's bookkeeping — the ReAct step tree, settings,
and client set — which
@@ -297,3 +299,807 @@ describe("TexeraAgent", () => {
});
});
});
+
+/**
+ * LanguageModelV4FinishReason is an object, not a string: a bare
`finishReason: "stop"` runs fine
+ * under `bun test` but fails `tsc --noEmit`, so the mocks below build it
through here.
+ */
+const finish = (unified: "stop" | "tool-calls") => ({ unified, raw: undefined
});
+
+/**
+ * LanguageModelV4Usage is nested. A flat `{ inputTokens: 11 }` is silently
discarded, and every
+ * usage assertion then passes against a gutted mapping — so usage is always
built through here.
+ * `totalTokens` is deliberately absent: the SDK derives it.
+ */
+const usage = (i: number, o: number) => ({
+ inputTokens: { total: i, noCache: i, cacheRead: 0, cacheWrite: 0 },
+ outputTokens: { total: o, text: o, reasoning: 0 },
+});
+
+const textModel = (text: string, i = 11, o = 7) =>
+ new MockLanguageModelV4({
+ doGenerate: async () => ({
+ content: [{ type: "text" as const, text }],
+ // `as const` on both: without it these widen to `string` and the mock
no longer satisfies
+ // LanguageModelV4GenerateResult, which `bun test` accepts but `tsc
--noEmit` rejects.
+ finishReason: finish("stop"),
+ usage: usage(i, o),
+ warnings: [],
+ }),
+ });
+
+/** Sibling of makeAgent() that takes a stand-in model, so sendMessage runs
with no network.
+ * Every agent built here is tracked and destroyed from `afterEach` rather
than per test: a
+ * failing assertion would skip an in-test `destroy()`, and a pending
auto-persist debounce
+ * could then fire after the fetch spy is restored and issue a real request.
*/
+const liveAgents: TexeraAgent[] = [];
+function makeAgentWith(model: any): TexeraAgent {
+ const agent = new TexeraAgent({ model, modelType: "test-model", agentId:
"agent-1", systemPrompt: "SYS-XYZ" });
+ liveAgents.push(agent);
+ return agent;
+}
+
+/** A source operator. `inputPorts: []` matters — a non-empty one fails
validateOperatorConnection
+ * and masks the delegate-mode assertions behind a validation error. */
+const srcOp = (id: string, props: Record<string, any> = {}) => ({
+ operatorID: id,
+ operatorType: "CSVFileScan",
+ operatorVersion: "1.0",
+ operatorProperties: props,
+ inputPorts: [],
+ outputPorts: [{ portID: "output-0" }],
+ showAdvanced: false,
+});
+
+/**
+ * A tripwire rather than a stub: with an empty workflow and no delegate
config, sendMessage makes
+ * no network calls at all, and the tests below assert that. Tests that do
need I/O install their
+ * own implementation over it.
+ */
+let fetchSpy: any;
+let urls: string[];
+
+beforeEach(() => {
+ urls = [];
+ fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (u: any) => {
+ urls.push(String(u));
+ throw new Error("unexpected fetch");
+ }) as any);
+});
+afterEach(() => {
+ // Destroy before restoring the spy — destruction is what cancels a pending
auto-persist.
+ for (const agent of liveAgents.splice(0)) agent.destroy();
+ fetchSpy.mockRestore();
+});
+
+/**
+ * sendMessage was the largest untested region in the file. Everything it
needs is in-process:
+ * `ai/test` supplies a model that never reaches the network, so the ReAct
loop, its usage
+ * accounting, its branch bookkeeping and its failure paths can all be driven
directly.
+ *
+ * One shape to know when adding tests here: a tool call's `input` must be a
JSON *string*, e.g.
+ * `{ type: "tool-call", toolCallId: "c1", toolName: "modifyOperator", input:
JSON.stringify(...) }`.
+ */
+describe("sendMessage", () => {
+ test("records the turn as a linear two-step branch", async () => {
+ const model = textModel("hello there");
+ const agent = makeAgentWith(model);
+ const res = await agent.sendMessage("12345678", "feedback");
+ const steps = agent.getAllSteps();
+ expect(res.response).toBe("hello there");
+ expect(res.stopped).toBe(false);
+ expect(res.error).toBeUndefined();
+ expect(res.messages).toEqual([{ role: "assistant", content: [{ type:
"text", text: "hello there" }] }]);
+ expect(res.usage).toEqual({ inputTokens: 11, outputTokens: 7, totalTokens:
18 });
+ expect(steps.map(s => [s.role, s.stepId, s.content, s.isBegin,
s.isEnd])).toEqual([
+ ["user", 0, "12345678", true, true],
+ ["agent", 1, "hello there", true, true],
+ ]);
+ expect(steps[0].usage).toEqual({ inputTokens: 2, outputTokens: 0,
totalTokens: 2 });
+ expect(steps[0].messageSource).toBe("feedback");
+ expect(steps[0].parentId).toBe(INITIAL_STEP_ID);
+ expect(steps[1].parentId).toBe(steps[0].id);
+ expect(agent.getHead()).toBe(steps[1].id);
+ expect(agent.getAncestorPath()).toEqual([INITIAL_STEP_ID, steps[0].id,
steps[1].id]);
+ expect(steps[0].id).toMatch(/^step-agent-1-1-\d+$/);
+ expect(steps[1].id).toMatch(/^step-agent-1-2-\d+$/);
+ expect(steps[0].messageId).toMatch(/^msg-agent-1-1-\d+$/);
+ expect(agent.getState()).toBe(AgentState.AVAILABLE);
+ expect((agent as any).abortController).toBeNull();
+ expect(urls).toEqual([]);
+ });
+
+ test("pinned call options + assembled context replaces the raw message",
async () => {
+ const model = textModel("ok");
+ const agent = makeAgentWith(model);
+ await agent.sendMessage("raw-user-text");
+ const call = (model as any).doGenerateCalls[0];
+ expect(call.temperature).toBe(0.2);
+ expect(call.providerOptions).toEqual({
+ openai: { parallelToolCalls: false },
+ anthropic: { disableParallelToolUse: true },
+ mistral: { parallelToolCalls: false },
+ });
+ expect(call.abortSignal).toBeInstanceOf(AbortSignal);
+ expect(call.abortSignal.aborted).toBe(false);
+ expect(call.tools.map((t: any) => t.name)).toEqual(["deleteOperator",
"addOperator", "modifyOperator"]);
+ expect(call.prompt[0]).toEqual({ role: "system", content: "SYS-XYZ" });
+ const txt = call.prompt[1].content[0].text;
+ expect(txt).toContain("# Ongoing Task");
+ expect(txt).toContain("raw-user-text");
+ expect(agent.getAllSteps()[1].inputMessages).toEqual([{ role: "user",
content: txt }]);
+ });
+
+ test("two-step tool run: per-step + summed usage, tool projection, rolling
snapshots", async () => {
+ let n = 0;
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ n++;
+ if (n === 1)
+ return {
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "c1",
+ toolName: "deleteOperator",
+ input: JSON.stringify({ operatorId: "ghost" }),
+ },
+ ],
+ finishReason: finish("tool-calls"),
+ usage: usage(100, 10),
+ warnings: [],
+ } as any;
+ return {
+ content: [{ type: "text", text: "done" }],
+ finishReason: finish("stop"),
+ usage: usage(200, 20),
+ warnings: [],
+ } as any;
+ },
+ });
+ const agent = makeAgentWith(model);
+ const res = await agent.sendMessage("delete it");
+ const steps = agent.getAllSteps();
+ expect(res.usage).toEqual({ inputTokens: 300, outputTokens: 30,
totalTokens: 330 });
+ expect(res.response).toBe("done");
+ expect(steps[1].usage).toEqual({ inputTokens: 100, outputTokens: 10,
totalTokens: 110 });
+ expect(steps[2].usage).toEqual({ inputTokens: 200, outputTokens: 20,
totalTokens: 220 });
+ expect(steps[1].toolCalls).toEqual([
+ { toolName: "deleteOperator", toolCallId: "c1", input: { operatorId:
"ghost" } },
+ ]);
+ expect(steps[1].toolResults).toEqual([
+ { toolCallId: "c1", output: "[ERROR] Operator ghost not found", isError:
false },
+ ]);
+ expect(steps[1].content).toBe("");
+ expect(steps.map(s => [s.stepId, s.isBegin, s.isEnd])).toEqual([
+ [0, true, true],
+ [1, true, false],
+ [2, false, true],
+ ]);
+ expect(steps[2].parentId).toBe(steps[1].id);
+ expect(urls).toEqual([]);
+ });
+
+ test("rolling before/after snapshots across a mutating step", async () => {
+ let n = 0;
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ n++;
+ if (n === 1)
+ return {
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "c1",
+ toolName: "deleteOperator",
+ input: JSON.stringify({ operatorId: "op1" }),
+ },
+ ],
+ finishReason: finish("tool-calls"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ return {
+ content: [{ type: "text", text: "x" }],
+ finishReason: finish("stop"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ },
+ });
+ const agent = makeAgentWith(model);
+ agent.getWorkflowState().addOperator(srcOp("op1") as any);
+ fetchSpy.mockImplementation((async (u: any) => {
+ urls.push(String(u));
+ return { ok: true, json: async () => ({ operatorOutputSchemas: {},
operatorErrors: {} }) } as any;
+ }) as any);
+ await agent.sendMessage("del");
+ const counts = agent
+ .getAllSteps()
+ .map(s => [s.role, s.beforeWorkflowContent?.operators.length,
s.afterWorkflowContent?.operators.length]);
+ expect(counts).toEqual([
+ ["user", 1, 1],
+ ["agent", 1, 0],
+ ["agent", 0, 0],
+ ]);
+ });
+
+ test("caps the loop at maxSteps", async () => {
+ // The cap is the only thing standing between a looping model and an
unbounded run.
+ let n = 0;
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ n++;
+ if (n > 20) throw new Error("runaway");
+ return {
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "c" + n,
+ toolName: "deleteOperator",
+ input: JSON.stringify({ operatorId: "ghost" }),
+ },
+ ],
+ finishReason: finish("tool-calls"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ },
+ });
+ const agent = makeAgentWith(model);
+ agent.updateSettings({ maxSteps: 2 });
+ await agent.sendMessage("go");
+ expect(n).toBe(2);
+ expect(agent.getAllSteps().map(s => s.stepId)).toEqual([0, 1, 2]);
+ });
+
+ test("second turn chains on and reads turn 1 as completed", async () => {
+ const model = textModel("a", 1, 1);
+ const agent = makeAgentWith(model);
+ await agent.sendMessage("one");
+ const headAfter1 = agent.getHead();
+ await agent.sendMessage("two");
+ const steps = agent.getAllSteps();
+ expect(steps.map(s => s.stepId)).toEqual([0, 1, 0, 1]);
+ expect(steps[2].parentId).toBe(headAfter1);
+ expect(steps[0].messageId).not.toBe(steps[2].messageId);
+ expect(steps[2].messageId).toMatch(/^msg-agent-1-2-\d+$/);
+ expect(steps[3].id).toMatch(/^step-agent-1-4-\d+$/);
+ const txt = (model as any).doGenerateCalls[1].prompt[1].content[0].text;
+ expect(txt).toContain("# Completed Tasks");
+ expect(txt).toContain("## Task (completed)");
+ expect(txt).toContain("# Ongoing Task");
+ });
+
+ test("abandoned branch is invisible to the model", async () => {
+ // Branches are how a retried turn discards its predecessor; if the
abandoned one still reached
+ // the prompt the model would answer against history the user already
rejected.
+ const model = textModel("a", 1, 1);
+ const agent = makeAgentWith(model);
+ await agent.sendMessage("first");
+ (agent as any).head = agent.getAllSteps()[0].id;
+ await agent.sendMessage("second");
+ const txt = (model as any).doGenerateCalls[1].prompt[1].content[0].text;
+ expect(txt).not.toContain("### Turn 1");
+ expect(agent.getAllSteps().length).toBe(4);
+ expect(agent.getVisibleReActSteps().length).toBe(3);
+ });
+
+ test("compiles the DAG and feeds schemas + cached results into the prompt",
async () => {
+ const model = textModel("ok", 1, 1);
+ const agent = makeAgentWith(model);
+ agent.getWorkflowState().addOperator(srcOp("op-1", { fileName: "f.csv" })
as any);
+ agent.getWorkflowResultState().set("op-1", INITIAL_STEP_ID, {
+ state: "COMPLETED",
+ inputTuples: 0,
+ outputTuples: 2,
+ resultMode: "SET_SNAPSHOT",
+ result: [{ a: 1 }, { a: 2 }],
+ } as any);
+ fetchSpy.mockImplementation((async (u: any) => {
+ urls.push(String(u));
+ return {
+ ok: true,
+ json: async () => ({
+ operatorOutputSchemas: { "op-1": { "0_0": [{ attributeName: "a",
attributeType: "integer" }] } },
+ operatorErrors: {},
+ }),
+ } as any;
+ }) as any);
+ const res = await agent.sendMessage("look");
+ const txt = (model as any).doGenerateCalls[0].prompt[1].content[0].text;
+ expect(res.error).toBeUndefined();
+ expect(urls).toEqual(["http://localhost:9090/api/compile"]);
+ expect(txt).toContain("Output Schema: [a: integer]");
+ expect(txt).toContain("(CSVFileScan, executed)");
+ expect(txt).toContain("Result:\n Executed operator op-1\n Output table
shape: (2, 1)");
+ expect(txt).toContain("Properties:\n fileName: f.csv");
+ });
+
+ test("swallows a plan-build failure and still answers", async () => {
+ const model = textModel("still-here", 1, 1);
+ const agent = makeAgentWith(model);
+ const ws = agent.getWorkflowState();
+ ws.addOperator({ ...srcOp("src"), outputPorts: undefined } as any);
+ ws.addOperator({ ...srcOp("dst"), inputPorts: [{ portID: "input-0" }] } as
any);
+ ws.addLink({
+ linkID: "l1",
+ source: { operatorID: "src", portID: "output-0" },
+ target: { operatorID: "dst", portID: "input-0" },
+ } as any);
+ const res = await agent.sendMessage("hi");
+ expect(res.response).toBe("still-here");
+ expect(res.error).toBeUndefined();
+ });
+
+ test("reports a model failure as an error step", async () => {
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ throw new Error("model exploded");
+ },
+ });
+ const agent = makeAgentWith(model);
+ const res = await agent.sendMessage("hi");
+ const steps = agent.getAllSteps();
+ expect(res).toEqual({
+ response: "",
+ messages: [],
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
+ stopped: false,
+ error: "model exploded",
+ });
+ expect(steps.map(s => [s.role, s.stepId, s.content, s.isBegin,
s.isEnd])).toEqual([
+ ["user", 0, "hi", true, true],
+ ["agent", 1, "Error: model exploded", false, true],
+ ]);
+ expect(agent.getHead()).toBe(steps[1].id);
+ expect(agent.getState()).toBe(AgentState.AVAILABLE);
+ expect((agent as any).abortController).toBeNull();
+ expect((agent as any).currentMessageId).toBeUndefined();
+ });
+
+ test("a non-Error throw is stringified", async () => {
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ throw "just-a-string";
+ },
+ });
+ const agent = makeAgentWith(model);
+ const res = await agent.sendMessage("hi");
+ expect(res.error).toBe("just-a-string");
+ expect(agent.getAllSteps()[1].content).toBe("Error: just-a-string");
+ });
+
+ test("a failed turn stays on the branch", async () => {
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ throw new Error("nope");
+ },
+ });
+ const agent = makeAgentWith(model);
+ await agent.sendMessage("one");
+ const headAfter1 = agent.getHead();
+ await agent.sendMessage("two");
+ expect(agent.getAllSteps()[2].parentId).toBe(headAfter1);
+ expect(agent.getVisibleReActSteps().length).toBe(4);
+ });
+
+ test("reports a cancelled run as stopped and swallows the real error", async
() => {
+ let agent!: TexeraAgent;
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ agent.stop();
+ throw new Error("real-provider-failure");
+ },
+ });
+ agent = makeAgentWith(model);
+ const res = await agent.sendMessage("hi");
+ const steps = agent.getAllSteps();
+ expect(res).toEqual({
+ response: "",
+ messages: [],
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
+ stopped: true,
+ });
+ expect(res.error).toBeUndefined();
+ expect(steps.map(s => [s.role, s.stepId, s.content, s.isBegin,
s.isEnd])).toEqual([
+ ["user", 0, "hi", true, true],
+ ["agent", 1, "Generation stopped by user.", false, true],
+ ]);
+ expect(JSON.stringify(steps)).not.toContain("real-provider-failure");
+ expect(agent.getHead()).toBe(steps[1].id);
+ expect(agent.getState()).toBe(AgentState.AVAILABLE);
+ });
+
+ test("an AbortError-named provider error reads as a user stop", async () => {
+ // Providers signal cancellation by name rather than by type, so the name
is what has to be read.
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ throw Object.assign(new Error("boom"), { name: "AbortError" });
+ },
+ });
+ const agent = makeAgentWith(model);
+ const res = await agent.sendMessage("hi");
+ expect(res.stopped).toBe(true);
+ expect(res.error).toBeUndefined();
+ expect(agent.getAllSteps()[1].content).toBe("Generation stopped by user.");
+ });
+
+ test("stop() mid-run prevents the next model call and keeps the partial
step", async () => {
+ // Stopping has to be observable at the next step boundary, and the work
already done has to
+ // survive - otherwise pressing stop silently discards the turn.
+ let agent!: TexeraAgent;
+ let n = 0;
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ n++;
+ agent.stop();
+ return {
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "c" + n,
+ toolName: "deleteOperator",
+ input: JSON.stringify({ operatorId: "ghost" }),
+ },
+ ],
+ finishReason: finish("tool-calls"),
+ usage: usage(5, 5),
+ warnings: [],
+ } as any;
+ },
+ });
+ agent = makeAgentWith(model);
+ const res = await agent.sendMessage("go");
+ expect(n).toBe(1);
+ expect(res.stopped).toBe(true);
+ expect(res.usage.totalTokens).toBe(0);
+ expect(res.messages).toEqual([]);
+ expect(agent.getAllSteps().map(s => [s.role, s.stepId, s.isEnd])).toEqual([
+ ["user", 0, true],
+ ["agent", 1, false],
+ ["agent", 2, true],
+ ]);
+ expect(agent.getHead()).toBe(agent.getAllSteps()[2].id);
+ });
+
+ test("mid-run state is GENERATING", async () => {
+ let release!: () => void;
+ const gate = new Promise<void>(r => (release = r));
+ const observed: string[] = [];
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ await gate;
+ return {
+ content: [{ type: "text", text: "x" }],
+ finishReason: finish("stop"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ },
+ });
+ const agent = makeAgentWith(model);
+ const p = agent.sendMessage("hi");
+ await new Promise(r => setTimeout(r, 5));
+ observed.push(agent.getState());
+ release();
+ await p;
+ expect(observed).toEqual([AgentState.GENERATING]);
+ expect(agent.getState()).toBe(AgentState.AVAILABLE);
+ });
+});
+
+/**
+ * With a delegate config the agent talks to the backend, so these drive it
through `fetch`.
+ *
+ * One trap. Setting a delegate config makes the first turn refresh from the
backend, and that
+ * refresh replaces the whole workflow — so operators have to arrive through
`dispatch`'s stub
+ * rather than being seeded on the agent. Destruction, which is what cancels
the auto-persist
+ * debounce, is centralized in the root `afterEach` so it runs even when a
test fails mid-way.
+ */
+describe("delegate mode", () => {
+ const wfBody = (ops: any[]) => ({
+ wid: 7,
+ name: "w",
+ content: {
+ operators: ops,
+ links: [],
+ operatorPositions: {},
+ commentBoxes: [],
+ settings: { dataTransferBatchSize: 400 },
+ },
+ });
+
+ /** Routes each backend call the agent makes to a canned body, and records
the URL. */
+ function dispatch(execBody: any, seed: any = srcOp("op-1")) {
+ fetchSpy.mockImplementation((async (u: any) => {
+ const url = String(u);
+ urls.push(url);
+ if (url.includes("/api/compile"))
+ return { ok: true, json: async () => ({ operatorOutputSchemas: {},
operatorErrors: {} }) } as any;
+ if (url.includes("/api/workflow/persist")) return { ok: true, json:
async () => wfBody([]) } as any;
+ if (url.includes("/api/workflow/")) return { ok: true, json: async () =>
wfBody([seed]) } as any;
+ return { ok: true, json: async () => execBody } as any;
+ }) as any);
+ }
+
+ /** A completed run. The field is `operators`, not `operatorResults`; with
the wrong key the
+ * result callback silently never fires and the assertions below all still
look plausible. */
+ const okExec = {
+ success: true,
+ state: "Completed",
+ operators: {
+ "op-1": {
+ state: "Completed",
+ inputTuples: 0,
+ outputTuples: 1,
+ resultMode: "table",
+ totalRowCount: 1,
+ result: [{ z: 9 }],
+ },
+ },
+ };
+
+ test("refreshes the workflow once, on the first turn only", async () => {
+ dispatch(okExec);
+ const model = textModel("ok", 1, 1);
+ const agent = makeAgentWith(model);
+ agent.setDelegateConfig({ userToken: "tok", workflowId: 7 });
+ await agent.sendMessage("one");
+ const retrieves = () => urls.filter(u =>
u.includes("/api/workflow/7")).length;
+ expect(retrieves()).toBe(1);
+ expect(
+ agent
+ .getWorkflowState()
+ .getAllOperators()
+ .map((o: any) => o.operatorID)
+ ).toEqual(["op-1"]);
+
expect(agent.getAllSteps()[0].beforeWorkflowContent?.operators.length).toBe(1);
+ await agent.sendMessage("two");
+ expect(retrieves()).toBe(1);
+ });
+
+ test("a failed refresh is swallowed", async () => {
+ fetchSpy.mockImplementation((async (u: any) => {
+ urls.push(String(u));
+ return { ok: false, status: 500, statusText: "err", text: async () =>
"boom" } as any;
+ }) as any);
+ const model = textModel("still-ok", 1, 1);
+ const agent = makeAgentWith(model);
+ agent.getWorkflowState().addOperator(srcOp("local-op") as any);
+ agent.setDelegateConfig({ userToken: "tok", workflowId: 7 });
+ const res = await agent.sendMessage("hi");
+ expect(res.response).toBe("still-ok");
+ expect(
+ agent
+ .getWorkflowState()
+ .getAllOperators()
+ .map((o: any) => o.operatorID)
+ ).toEqual(["local-op"]);
+ });
+
+ test("auto-executes after modifyOperator and keys the result at the agent
step", async () => {
+ dispatch(okExec);
+ const vSpy = spyOn(WorkflowSystemMetadata.getInstance(),
"validateOperatorProperties").mockReturnValue({
+ isValid: true,
+ } as any);
+ try {
+ let n = 0;
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ n++;
+ if (n === 1)
+ return {
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "c1",
+ toolName: "modifyOperator",
+ input: JSON.stringify({ operatorId: "op-1", summary:
"renamed" }),
+ },
+ ],
+ finishReason: finish("tool-calls"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ return {
+ content: [{ type: "text", text: "d" }],
+ finishReason: finish("stop"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ },
+ });
+ const agent = makeAgentWith(model);
+ agent.setDelegateConfig({ userToken: "tok", workflowId: 7, workflowName:
"w" });
+ await agent.sendMessage("modify it");
+ const steps = agent.getAllSteps();
+ const txt2 = (model as any).doGenerateCalls[1].prompt[1].content[0].text;
+ expect(urls.some(u => u.includes("/api/execution/7/0/run"))).toBe(true);
+ expect((agent.getWorkflowResultState() as
any).get("op-1").stepId).toBe(steps[1].id);
+ } finally {
+ // A leaked always-valid stub would let the rejected-modification test
below pass validation
+ // and execute, so the restore has to survive a failed assertion.
+ vSpy.mockRestore();
+ }
+ });
+
+ test("executeOperator tool keys its result at the current head (the user
step)", async () => {
+ // The contrast with the previous test is the point: an explicit
executeOperator call has no agent
+ // step of its own yet, so its result belongs at the head rather than at a
step that follows it.
+ dispatch(okExec);
+ const vSpy = spyOn(WorkflowSystemMetadata.getInstance(),
"validateOperatorProperties").mockReturnValue({
+ isValid: true,
+ } as any);
+ try {
+ let n = 0;
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ n++;
+ if (n === 1)
+ return {
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "c1",
+ toolName: "executeOperator",
+ input: JSON.stringify({ operatorId: "op-1" }),
+ },
+ ],
+ finishReason: finish("tool-calls"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ return {
+ content: [{ type: "text", text: "d" }],
+ finishReason: finish("stop"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ },
+ });
+ const agent = makeAgentWith(model);
+ agent.setDelegateConfig({ userToken: "tok", workflowId: 7, workflowName:
"w" });
+ await agent.sendMessage("run it");
+ const steps = agent.getAllSteps();
+ expect(urls.filter(u => u.includes("/api/execution/")).length).toBe(1);
+ expect((agent.getWorkflowResultState() as
any).get("op-1").stepId).toBe(steps[0].id);
+ } finally {
+ vSpy.mockRestore();
+ }
+ });
+
+ test("a tool call missing operatorId does not trigger a whole-workflow run",
async () => {
+ // Without the guard an incomplete tool call falls through to a run of the
entire workflow, which is
+ // both expensive and not what was asked for.
+ dispatch(okExec);
+ let n = 0;
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ n++;
+ if (n === 1)
+ return {
+ content: [{ type: "tool-call", toolCallId: "c1", toolName:
"modifyOperator", input: JSON.stringify({}) }],
+ finishReason: finish("tool-calls"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ return {
+ content: [{ type: "text", text: "d" }],
+ finishReason: finish("stop"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ },
+ });
+ const agent = makeAgentWith(model);
+ agent.setDelegateConfig({ userToken: "tok", workflowId: 7 });
+ await agent.sendMessage("bad call");
+ const step = agent.getAllSteps()[1];
+ expect(step.toolResults).toEqual([]);
+ expect(urls.some(u => u.includes("/api/execution/"))).toBe(false);
+ });
+
+ test("a rejected modification suppresses the follow-up execution", async ()
=> {
+ // A modification the validator rejected did not change anything, so
executing afterwards would run
+ // the old workflow and report it as the result of the change.
+ dispatch(okExec, srcOp("op-1", { fileName: "a.csv" }));
+ const saved = (WorkflowSystemMetadata as any).instance;
+ (WorkflowSystemMetadata as any).instance = undefined;
+ try {
+ WorkflowSystemMetadata.getInstance().loadFromMetadata({
+ operators: [
+ {
+ operatorType: "CSVFileScan",
+ jsonSchema: { type: "object", properties: { fileName: { type:
"string" } }, required: ["fileName"] },
+ additionalMetadata: { userFriendlyName: "CSV",
operatorDescription: "csv" },
+ },
+ ],
+ } as any);
+ let n = 0;
+ const model = new MockLanguageModelV4({
+ doGenerate: async () => {
+ n++;
+ if (n === 1)
+ return {
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "c1",
+ toolName: "modifyOperator",
+ input: JSON.stringify({ operatorId: "op-1", properties: {
fileName: 123 }, summary: "s" }),
+ },
+ ],
+ finishReason: finish("tool-calls"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ return {
+ content: [{ type: "text", text: "d" }],
+ finishReason: finish("stop"),
+ usage: usage(1, 1),
+ warnings: [],
+ } as any;
+ },
+ });
+ const agent = makeAgentWith(model);
+ agent.setDelegateConfig({ userToken: "tok", workflowId: 7 });
+ await agent.sendMessage("bad props");
+ const step = agent.getAllSteps()[1];
+ expect(String(step.toolResults?.[0]?.output)).toStartWith("[ERROR]");
+ expect(urls.some(u => u.includes("/api/execution/"))).toBe(false);
+ } finally {
+ // The swap must be undone even on a failed assertion, or the stub
metadata leaks into
+ // every later test that touches the singleton.
+ (WorkflowSystemMetadata as any).instance = saved;
+ }
+ });
+
+ test("buildExecutionConfig projects the delegate config and live settings",
async () => {
+ const agent = makeAgentWith(textModel("x"));
+ expect((agent as any).buildExecutionConfig()).toBeUndefined();
+ (agent as any).delegateConfig = { userToken: "tok", workflowId: 5,
computingUnitId: 2 };
+ agent.updateSettings({
+ executionTimeoutMs: 7000,
+ maxOperatorResultCharLimit: 11,
+ maxOperatorResultCellCharLimit: 13,
+ });
+ expect((agent as any).buildExecutionConfig()).toEqual({
+ userToken: "tok",
+ workflowId: 5,
+ computingUnitId: 2,
+ maxOperatorResultCharLimit: 11,
+ maxOperatorResultCellCharLimit: 13,
+ executionTimeoutMs: 7000,
+ });
+ });
+
+ test("auto-persist coalesces a burst into one request under the delegate's
name", async () => {
+ // The debounce is what keeps a burst of edits from becoming a burst of
writes.
+ dispatch(okExec);
+ const agent = makeAgentWith(textModel("x"));
+ agent.setDelegateConfig({ userToken: "tok", workflowId: 7, workflowName:
"My Flow" });
+ agent.setDelegateConfig({ userToken: "tok", workflowId: 7, workflowName:
"My Flow" });
+ agent.getWorkflowState().addOperator(srcOp("o1") as any);
+ agent.getWorkflowState().addOperator(srcOp("o2") as any);
+ await new Promise(r => setTimeout(r, 700));
+ const persists = fetchSpy.mock.calls.filter((c: any) =>
String(c[0]).includes("/api/workflow/persist"));
+ expect(persists.length).toBe(1);
+ expect(JSON.parse(persists[0][1].body).name).toBe("My Flow");
+
expect(JSON.parse(JSON.parse(persists[0][1].body).content).operators.map((o:
any) => o.operatorID)).toEqual([
+ "o1",
+ "o2",
+ ]);
+ });
+
+ test("a failed auto-persist is logged, not thrown", async () => {
+ const errs: any[] = [];
+ const agent = makeAgentWith(textModel("x"));
+ (agent as any).log = { error: (...a: any[]) => errs.push(a), debug: () =>
{}, warn: () => {}, info: () => {} };
+ agent.setDelegateConfig({ userToken: "tok", workflowId: 7 });
+ agent.getWorkflowState().addOperator(srcOp("o1") as any);
+ await new Promise(r => setTimeout(r, 700));
+ expect(errs.length).toBe(1);
+ expect(errs[0][1]).toBe("failed to auto-persist workflow");
+ });
+});